Compare commits
60 Commits
f1f30bcb2f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a9eaf0114f | |||
| f63172c4cf | |||
| ff111ef278 | |||
| bf5470ac66 | |||
| c1e3b2881d | |||
| 4024740b82 | |||
| fb4cf1b035 | |||
| d2daf74fca | |||
| e52e818686 | |||
| df6cf94dae | |||
| 7dcb8af1bb | |||
| 6be9bf5aff | |||
| 7c37e9adb3 | |||
| 0780b4c17d | |||
| a08dc505d9 | |||
| c82726b691 | |||
| c14c0d843d | |||
| 0fd0e74b67 | |||
| 1636784931 | |||
| c30ea2d679 | |||
| 769391640b | |||
| ecf375205f | |||
| f2f8a03a32 | |||
| 630893f047 | |||
| 928fa7191b | |||
| fae8730477 | |||
| 7e563bd334 | |||
| 9ae8ff85c3 | |||
| f9e9be62bc | |||
| 5b9a85cd37 | |||
| 91733f5460 | |||
| 380e2005c8 | |||
| 62050faedc | |||
| f3cb082c36 | |||
| db0d9f97b2 | |||
| 94603b19a5 | |||
| 45b7e4bcf7 | |||
| 7ca8caeecb | |||
| c21b152de8 | |||
| 4fe8957482 | |||
| 1b432f1c3f | |||
| 383e2cbf38 | |||
| 1f5029cbb0 | |||
| 89255cc6fa | |||
| 652ceb55f0 | |||
| 02f9cf7d8f | |||
| 2105803594 | |||
| 73400a21ab | |||
| 56b455a074 | |||
| f07022ca60 | |||
| 91fa0608d0 | |||
| e43e052a32 | |||
| 6ef58472cf | |||
| 48a1c9cd1d | |||
| 343d2e4237 | |||
| c21665284a | |||
| daa4de8832 | |||
| ae958528a6 | |||
| de89f3066c | |||
| 4169f5b9a4 |
@@ -0,0 +1,23 @@
|
||||
---
|
||||
active: true
|
||||
iteration: 1
|
||||
max_iterations: 20
|
||||
completion_promise: "Guardrail hooks are fully implemented, tested, and registered"
|
||||
started_at: "2026-01-07T18:52:10Z"
|
||||
---
|
||||
|
||||
Implement guardrail hooks following the design at ~/.claude/docs/plans/2025-01-06-guardrail-hooks-design.md
|
||||
|
||||
Implementation order:
|
||||
1. Create state/guardrails.json with starter rules config
|
||||
2. Create hooks/scripts/guardrail.py main logic
|
||||
3. Create hooks/scripts/guardrail-confirm.py confirm helper
|
||||
4. Modify hooks/hooks.json to add PreToolUse registration
|
||||
5. Modify hooks/scripts/session-end.sh to clear session allowlist
|
||||
6. Create logs/ directory
|
||||
7. Test: block scenario (catastrophic command pattern)
|
||||
8. Test: confirm scenario (operation outside safe paths)
|
||||
9. Test: allow scenario (operation in safe path)
|
||||
10. Test: git-aware detection
|
||||
|
||||
Register in component-registry.json when complete.
|
||||
@@ -45,3 +45,10 @@ tmp_unused
|
||||
# Todos (managed by Claude Code)
|
||||
todos/
|
||||
repos/homelab
|
||||
|
||||
# RAG search data (generated vector stores and caches)
|
||||
data/
|
||||
skills/rag-search/venv/
|
||||
|
||||
# Telemetry (analytics tracking)
|
||||
telemetry/
|
||||
|
||||
@@ -45,6 +45,8 @@ See `agents/README.md` for details on agent files and execution.
|
||||
├── commands/ # Slash command definitions
|
||||
├── workflows/ # Workflow definitions (design docs)
|
||||
│ └── README.md
|
||||
├── plans/ # Implementation plans
|
||||
│ └── index.json # Plan status registry
|
||||
├── state/ # Shared state files (JSON)
|
||||
│ ├── sysadmin/
|
||||
│ ├── programmer/
|
||||
@@ -67,6 +69,7 @@ All agents MUST read and follow the processes defined in these files:
|
||||
| `state/personal-assistant-preferences.json` | PA persistent config | personal-assistant |
|
||||
| `state/personal-assistant/general-instructions.json` | User memory | personal-assistant |
|
||||
| `state/kb.json` | Shared knowledge base | personal-assistant |
|
||||
| `plans/index.json` | Plan status registry | any agent |
|
||||
|
||||
## Key Processes
|
||||
|
||||
@@ -80,6 +83,44 @@ All agents MUST read and follow the processes defined in these files:
|
||||
- Session overrides in `state/sysadmin/session-autonomy.json`
|
||||
- See `state/autonomy-levels.json` for level definitions
|
||||
|
||||
### Git Workflow
|
||||
This repo uses Gitea (not GitHub). Follow this workflow:
|
||||
|
||||
1. **Create feature branch**: `git checkout -b feature/descriptive-name`
|
||||
2. **Commit work**: Make atomic commits with clear messages
|
||||
3. **Rebase onto main**: `git rebase origin/main`
|
||||
4. **Push branch**: `git push -u origin feature/branch-name`
|
||||
5. **Create PR**: `~/.claude/automation/gitea-pr.sh "PR Title" "Description"`
|
||||
6. **Merge** (after user approval): `~/.claude/automation/gitea-merge.sh`
|
||||
- Rebases onto main and pushes
|
||||
- Deletes local and remote branch
|
||||
- Closes PR via API
|
||||
|
||||
Notes:
|
||||
- Gitea token stored at `~/.config/gitea-token`
|
||||
|
||||
### PR Review Policy
|
||||
|
||||
| Repo Type | Review Process |
|
||||
|-----------|----------------|
|
||||
| ~/.claude | Linting/validation only (shellcheck, JSON/YAML syntax, Python syntax) |
|
||||
| Code repos | Full review via code-reviewer agent (Sonnet) before user approval |
|
||||
|
||||
### Gitea API Commands
|
||||
|
||||
```bash
|
||||
# List all PRs
|
||||
curl -s -H "Authorization: token $(cat ~/.config/gitea-token)" \
|
||||
"https://gitea-http.taildb3494.ts.net/api/v1/repos/will/claude-code/pulls?state=all"
|
||||
|
||||
# Close a PR (after rebase merge)
|
||||
curl -s -X PATCH \
|
||||
-H "Authorization: token $(cat ~/.config/gitea-token)" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"state":"closed"}' \
|
||||
"https://gitea-http.taildb3494.ts.net/api/v1/repos/will/claude-code/pulls/{PR_NUMBER}"
|
||||
```
|
||||
|
||||
## Component Formats
|
||||
|
||||
| Component | Format | Location |
|
||||
@@ -88,6 +129,7 @@ All agents MUST read and follow the processes defined in these files:
|
||||
| **Skills** | SKILL.md + scripts/ + references/ | `skills/` |
|
||||
| **Commands** | Markdown + YAML frontmatter | `commands/` |
|
||||
| **Workflows** | YAML (design docs, not auto-executed) | `workflows/` |
|
||||
| **Plans** | Markdown + index.json | `plans/` |
|
||||
| **State** | JSON | `state/` |
|
||||
| **Hooks** | JSON | `hooks/` |
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ claude
|
||||
| **Personal Assistant** | Natural language interface via `/pa` |
|
||||
| **Gmail Integration** | Read emails, check urgent, search |
|
||||
| **Calendar Integration** | View agenda, check schedule |
|
||||
| **Tasks Integration** | List Google Tasks |
|
||||
| **Kubernetes Management** | Cluster health, deployments, diagnostics |
|
||||
| **System Administration** | Health checks, updates, autonomy control |
|
||||
| **Usage Tracking** | Session statistics and history |
|
||||
@@ -54,6 +55,7 @@ Each directory has its own README with details.
|
||||
|---------|-------------|
|
||||
| `/pa <request>` | Natural language request to personal assistant |
|
||||
| `/gcal [today\|tomorrow\|week]` | Calendar agenda |
|
||||
| `/tasks` | List Google Tasks |
|
||||
| `/usage [today\|week\|month]` | Usage statistics |
|
||||
| `/sysadmin:health` | System health check |
|
||||
| `/sysadmin:update` | Package updates |
|
||||
@@ -68,6 +70,7 @@ Skills are triggered automatically based on your request:
|
||||
|---------|-------|
|
||||
| "check my email" | gmail |
|
||||
| "what's on today" | gcal |
|
||||
| "my tasks" / "todos" | gtasks |
|
||||
| "cluster status" | k8s-quick-status |
|
||||
| "system health" | sysadmin-health |
|
||||
| "usage stats" | usage |
|
||||
|
||||
Executable
+284
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Show information about available agents.
|
||||
Usage: python3 agent-info.py [--tree] [name]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
CLAUDE_DIR = Path.home() / ".claude"
|
||||
AGENTS_DIR = CLAUDE_DIR / "agents"
|
||||
REGISTRY_PATH = CLAUDE_DIR / "state" / "component-registry.json"
|
||||
|
||||
|
||||
# Agent hierarchy (from CLAUDE.md)
|
||||
HIERARCHY = {
|
||||
"personal-assistant": {
|
||||
"supervisor": None,
|
||||
"subordinates": ["master-orchestrator"]
|
||||
},
|
||||
"master-orchestrator": {
|
||||
"supervisor": "personal-assistant",
|
||||
"subordinates": ["linux-sysadmin", "k8s-orchestrator", "programmer-orchestrator"]
|
||||
},
|
||||
"linux-sysadmin": {
|
||||
"supervisor": "master-orchestrator",
|
||||
"subordinates": []
|
||||
},
|
||||
"k8s-orchestrator": {
|
||||
"supervisor": "master-orchestrator",
|
||||
"subordinates": ["k8s-diagnostician", "argocd-operator", "prometheus-analyst", "git-operator"]
|
||||
},
|
||||
"k8s-diagnostician": {
|
||||
"supervisor": "k8s-orchestrator",
|
||||
"subordinates": []
|
||||
},
|
||||
"argocd-operator": {
|
||||
"supervisor": "k8s-orchestrator",
|
||||
"subordinates": []
|
||||
},
|
||||
"prometheus-analyst": {
|
||||
"supervisor": "k8s-orchestrator",
|
||||
"subordinates": []
|
||||
},
|
||||
"git-operator": {
|
||||
"supervisor": "k8s-orchestrator",
|
||||
"subordinates": []
|
||||
},
|
||||
"programmer-orchestrator": {
|
||||
"supervisor": "master-orchestrator",
|
||||
"subordinates": ["code-planner", "code-implementer", "code-reviewer"]
|
||||
},
|
||||
"code-planner": {
|
||||
"supervisor": "programmer-orchestrator",
|
||||
"subordinates": []
|
||||
},
|
||||
"code-implementer": {
|
||||
"supervisor": "programmer-orchestrator",
|
||||
"subordinates": []
|
||||
},
|
||||
"code-reviewer": {
|
||||
"supervisor": "programmer-orchestrator",
|
||||
"subordinates": []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def load_registry() -> Dict:
|
||||
"""Load component registry."""
|
||||
try:
|
||||
with open(REGISTRY_PATH) as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def find_agent_files() -> List[Path]:
|
||||
"""Find all agent markdown files."""
|
||||
if not AGENTS_DIR.exists():
|
||||
return []
|
||||
|
||||
return [f for f in AGENTS_DIR.glob("*.md")
|
||||
if f.name != "README.md"]
|
||||
|
||||
|
||||
def parse_agent_md(path: Path) -> Dict:
|
||||
"""Parse an agent markdown file for metadata."""
|
||||
try:
|
||||
content = path.read_text()
|
||||
|
||||
result = {
|
||||
"name": path.stem,
|
||||
"path": str(path.relative_to(CLAUDE_DIR)),
|
||||
"description": "",
|
||||
"model": "unknown",
|
||||
"tools": [],
|
||||
}
|
||||
|
||||
# Parse YAML frontmatter
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 2:
|
||||
frontmatter = parts[1]
|
||||
for line in frontmatter.strip().split("\n"):
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if key == "name":
|
||||
result["name"] = value
|
||||
elif key == "description":
|
||||
result["description"] = value
|
||||
elif key == "model":
|
||||
result["model"] = value
|
||||
elif key == "tools":
|
||||
result["tools"] = [t.strip() for t in value.split(",")]
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return {"name": path.stem, "error": str(e)}
|
||||
|
||||
|
||||
def get_model_emoji(model: str) -> str:
|
||||
"""Get emoji for model type."""
|
||||
return {
|
||||
"opus": "🔷",
|
||||
"sonnet": "🔶",
|
||||
"haiku": "🔸"
|
||||
}.get(model.lower(), "○")
|
||||
|
||||
|
||||
def list_agents():
|
||||
"""List all available agents."""
|
||||
registry = load_registry()
|
||||
reg_agents = registry.get("agents", {})
|
||||
|
||||
print(f"\n🤖 Available Agents ({len(reg_agents)})\n")
|
||||
|
||||
# Group by model
|
||||
by_model = {"opus": [], "sonnet": [], "haiku": [], "unknown": []}
|
||||
|
||||
for name, info in reg_agents.items():
|
||||
model = info.get("model", "unknown")
|
||||
by_model.get(model, by_model["unknown"]).append({
|
||||
"name": name,
|
||||
"description": info.get("description", "No description"),
|
||||
"triggers": info.get("triggers", [])
|
||||
})
|
||||
|
||||
for model in ["opus", "sonnet", "haiku"]:
|
||||
agents = by_model[model]
|
||||
if not agents:
|
||||
continue
|
||||
|
||||
emoji = get_model_emoji(model)
|
||||
print(f"=== {model.title()} {emoji} ===")
|
||||
|
||||
for agent in sorted(agents, key=lambda a: a["name"]):
|
||||
print(f" {agent['name']}")
|
||||
print(f" {agent['description']}")
|
||||
if agent['triggers']:
|
||||
print(f" Triggers: {', '.join(agent['triggers'][:3])}")
|
||||
|
||||
print("")
|
||||
|
||||
|
||||
def show_tree():
|
||||
"""Show agent hierarchy as a tree."""
|
||||
print(f"\n🌳 Agent Hierarchy\n")
|
||||
|
||||
def print_tree(name: str, prefix: str = "", is_last: bool = True):
|
||||
info = HIERARCHY.get(name, {})
|
||||
registry = load_registry()
|
||||
reg_info = registry.get("agents", {}).get(name, {})
|
||||
model = reg_info.get("model", "?")
|
||||
emoji = get_model_emoji(model)
|
||||
|
||||
connector = "└── " if is_last else "├── "
|
||||
print(f"{prefix}{connector}{name} {emoji} ({model})")
|
||||
|
||||
new_prefix = prefix + (" " if is_last else "│ ")
|
||||
subordinates = info.get("subordinates", [])
|
||||
|
||||
for i, sub in enumerate(subordinates):
|
||||
print_tree(sub, new_prefix, i == len(subordinates) - 1)
|
||||
|
||||
# Start from root
|
||||
print_tree("personal-assistant")
|
||||
print("")
|
||||
|
||||
print("Legend: 🔷 opus 🔶 sonnet 🔸 haiku")
|
||||
print("")
|
||||
|
||||
|
||||
def show_agent(name: str):
|
||||
"""Show details for a specific agent."""
|
||||
registry = load_registry()
|
||||
reg_agents = registry.get("agents", {})
|
||||
|
||||
# Find matching agent
|
||||
matches = [n for n in reg_agents.keys() if name.lower() in n.lower()]
|
||||
|
||||
if not matches:
|
||||
print(f"Agent '{name}' not found.")
|
||||
print("\nAvailable agents:")
|
||||
for n in sorted(reg_agents.keys()):
|
||||
print(f" - {n}")
|
||||
return
|
||||
|
||||
if len(matches) > 1 and name not in matches:
|
||||
print(f"Multiple matches for '{name}':")
|
||||
for m in matches:
|
||||
print(f" - {m}")
|
||||
return
|
||||
|
||||
agent_name = name if name in matches else matches[0]
|
||||
reg_info = reg_agents[agent_name]
|
||||
|
||||
print(f"\n🤖 Agent: {agent_name}\n")
|
||||
|
||||
model = reg_info.get("model", "unknown")
|
||||
print(f"Model: {model} {get_model_emoji(model)}")
|
||||
print(f"Description: {reg_info.get('description', 'No description')}")
|
||||
|
||||
# Triggers
|
||||
triggers = reg_info.get("triggers", [])
|
||||
if triggers:
|
||||
print(f"\nTriggers:")
|
||||
for t in triggers:
|
||||
print(f" - {t}")
|
||||
|
||||
# Hierarchy
|
||||
hier = HIERARCHY.get(agent_name, {})
|
||||
supervisor = hier.get("supervisor")
|
||||
subordinates = hier.get("subordinates", [])
|
||||
|
||||
print(f"\nHierarchy:")
|
||||
if supervisor:
|
||||
print(f" Supervisor: {supervisor}")
|
||||
else:
|
||||
print(f" Supervisor: (top-level)")
|
||||
|
||||
if subordinates:
|
||||
print(f" Subordinates:")
|
||||
for sub in subordinates:
|
||||
sub_info = reg_agents.get(sub, {})
|
||||
sub_model = sub_info.get("model", "?")
|
||||
print(f" - {sub} ({sub_model})")
|
||||
|
||||
# Check for agent file
|
||||
agent_file = AGENTS_DIR / f"{agent_name}.md"
|
||||
if agent_file.exists():
|
||||
print(f"\nFile: agents/{agent_name}.md")
|
||||
file_info = parse_agent_md(agent_file)
|
||||
if file_info.get("tools"):
|
||||
print(f"Tools: {', '.join(file_info['tools'])}")
|
||||
|
||||
print("")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Show agent information")
|
||||
parser.add_argument("name", nargs="?", help="Agent name to show details")
|
||||
parser.add_argument("--tree", "-t", action="store_true",
|
||||
help="Show agent hierarchy tree")
|
||||
parser.add_argument("--list", "-l", action="store_true", help="List all agents")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.tree:
|
||||
show_tree()
|
||||
elif args.name and not args.list:
|
||||
show_agent(args.name)
|
||||
else:
|
||||
list_agents()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -51,6 +51,36 @@ _claude_upgrade() {
|
||||
COMPREPLY=($(compgen -W "--check --backup --apply --help" -- "${cur}"))
|
||||
}
|
||||
|
||||
_claude_workflow() {
|
||||
local cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
|
||||
COMPREPLY=($(compgen -W "--category --list" -- "${cur}"))
|
||||
}
|
||||
|
||||
_claude_skill_info() {
|
||||
local cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
|
||||
COMPREPLY=($(compgen -W "--scripts --list" -- "${cur}"))
|
||||
}
|
||||
|
||||
_claude_agent_info() {
|
||||
local cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
|
||||
COMPREPLY=($(compgen -W "--tree --list" -- "${cur}"))
|
||||
}
|
||||
|
||||
_claude_diff() {
|
||||
local cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
|
||||
COMPREPLY=($(compgen -W "--backup --list --json" -- "${cur}"))
|
||||
}
|
||||
|
||||
_claude_template() {
|
||||
local cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
|
||||
COMPREPLY=($(compgen -W "--list --create --use --delete" -- "${cur}"))
|
||||
}
|
||||
|
||||
_claude_memory_add() {
|
||||
local cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
local prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
@@ -87,6 +117,11 @@ complete -F _claude_debug debug.sh
|
||||
complete -F _claude_export session-export.py
|
||||
complete -F _claude_mcp_status mcp-status.sh
|
||||
complete -F _claude_upgrade upgrade.sh
|
||||
complete -F _claude_workflow workflow-info.py
|
||||
complete -F _claude_skill_info skill-info.py
|
||||
complete -F _claude_agent_info agent-info.py
|
||||
complete -F _claude_diff config-diff.py
|
||||
complete -F _claude_template session-template.py
|
||||
|
||||
# Alias completions for convenience
|
||||
alias claude-validate='~/.claude/automation/validate-setup.sh'
|
||||
@@ -106,7 +141,13 @@ alias claude-debug='~/.claude/automation/debug.sh'
|
||||
alias claude-export='python3 ~/.claude/automation/session-export.py'
|
||||
alias claude-mcp='~/.claude/automation/mcp-status.sh'
|
||||
alias claude-upgrade='~/.claude/automation/upgrade.sh'
|
||||
alias claude-workflow='python3 ~/.claude/automation/workflow-info.py'
|
||||
alias claude-skill='python3 ~/.claude/automation/skill-info.py'
|
||||
alias claude-agent='python3 ~/.claude/automation/agent-info.py'
|
||||
alias claude-diff='python3 ~/.claude/automation/config-diff.py'
|
||||
alias claude-template='python3 ~/.claude/automation/session-template.py'
|
||||
|
||||
echo "Claude Code completions loaded. Available aliases:"
|
||||
echo " claude-{validate,status,backup,restore,clean,memory-add,memory-list}"
|
||||
echo " claude-{search,history,install,test,maintenance,log,debug,export,mcp,upgrade}"
|
||||
echo " claude-{validate,status,backup,restore,clean,memory-add,memory-list,search}"
|
||||
echo " claude-{history,install,test,maintenance,log,debug,export,mcp,upgrade}"
|
||||
echo " claude-{workflow,skill,agent,diff,template}"
|
||||
|
||||
@@ -107,6 +107,48 @@ _claude_upgrade() {
|
||||
'--help[Show help]'
|
||||
}
|
||||
|
||||
# Workflow completion
|
||||
_claude_workflow() {
|
||||
_arguments \
|
||||
'--category[Filter by category]:category:(health deploy incidents sysadmin)' \
|
||||
'--list[List all workflows]' \
|
||||
'*:name:'
|
||||
}
|
||||
|
||||
# Skill info completion
|
||||
_claude_skill_info() {
|
||||
_arguments \
|
||||
'--scripts[Show scripts in listing]' \
|
||||
'--list[List all skills]' \
|
||||
'*:name:'
|
||||
}
|
||||
|
||||
# Agent info completion
|
||||
_claude_agent_info() {
|
||||
_arguments \
|
||||
'--tree[Show hierarchy tree]' \
|
||||
'--list[List all agents]' \
|
||||
'*:name:'
|
||||
}
|
||||
|
||||
# Config diff completion
|
||||
_claude_diff() {
|
||||
_arguments \
|
||||
'--backup[Backup file]:file:_files' \
|
||||
'--list[List backups]' \
|
||||
'--json[JSON output]'
|
||||
}
|
||||
|
||||
# Template completion
|
||||
_claude_template() {
|
||||
_arguments \
|
||||
'--list[List templates]' \
|
||||
'--create[Create template]:name:' \
|
||||
'--use[Use template]:name:' \
|
||||
'--delete[Delete template]:name:' \
|
||||
'--non-interactive[Non-interactive mode]'
|
||||
}
|
||||
|
||||
# Register completions
|
||||
compdef _memory_add memory-add.py
|
||||
compdef _memory_list memory-list.py
|
||||
@@ -118,6 +160,11 @@ compdef _claude_debug debug.sh
|
||||
compdef _claude_export session-export.py
|
||||
compdef _claude_mcp_status mcp-status.sh
|
||||
compdef _claude_upgrade upgrade.sh
|
||||
compdef _claude_workflow workflow-info.py
|
||||
compdef _claude_skill_info skill-info.py
|
||||
compdef _claude_agent_info agent-info.py
|
||||
compdef _claude_diff config-diff.py
|
||||
compdef _claude_template session-template.py
|
||||
|
||||
# Aliases
|
||||
alias claude-validate='~/.claude/automation/validate-setup.sh'
|
||||
@@ -137,7 +184,13 @@ alias claude-debug='~/.claude/automation/debug.sh'
|
||||
alias claude-export='python3 ~/.claude/automation/session-export.py'
|
||||
alias claude-mcp='~/.claude/automation/mcp-status.sh'
|
||||
alias claude-upgrade='~/.claude/automation/upgrade.sh'
|
||||
alias claude-workflow='python3 ~/.claude/automation/workflow-info.py'
|
||||
alias claude-skill='python3 ~/.claude/automation/skill-info.py'
|
||||
alias claude-agent='python3 ~/.claude/automation/agent-info.py'
|
||||
alias claude-diff='python3 ~/.claude/automation/config-diff.py'
|
||||
alias claude-template='python3 ~/.claude/automation/session-template.py'
|
||||
|
||||
echo "Claude Code completions loaded (zsh)"
|
||||
echo " Aliases: claude-{validate,status,backup,restore,clean,memory-add,memory-list}"
|
||||
echo " claude-{search,history,install,test,maintenance,log,debug,export,mcp,upgrade}"
|
||||
echo " Aliases: claude-{validate,status,backup,restore,clean,memory-add,memory-list,search}"
|
||||
echo " claude-{history,install,test,maintenance,log,debug,export,mcp,upgrade}"
|
||||
echo " claude-{workflow,skill,agent,diff,template}"
|
||||
|
||||
Executable
+294
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Compare current configuration with backup or default.
|
||||
Usage: python3 config-diff.py [--backup FILE] [--default] [--json]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
CLAUDE_DIR = Path.home() / ".claude"
|
||||
BACKUP_DIR = CLAUDE_DIR / "backups"
|
||||
|
||||
|
||||
def load_json_safe(path: Path) -> Optional[Dict]:
|
||||
"""Load JSON file safely."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def get_config_files() -> List[Path]:
|
||||
"""Get list of configuration files to compare."""
|
||||
patterns = [
|
||||
"CLAUDE.md",
|
||||
"VERSION",
|
||||
".claude-plugin/plugin.json",
|
||||
"hooks/hooks.json",
|
||||
"state/*.json",
|
||||
"state/**/*.json",
|
||||
]
|
||||
|
||||
files = []
|
||||
for pattern in patterns:
|
||||
files.extend(CLAUDE_DIR.glob(pattern))
|
||||
|
||||
return sorted(files)
|
||||
|
||||
|
||||
def extract_backup(backup_path: Path) -> Path:
|
||||
"""Extract backup to temporary directory."""
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
|
||||
with tarfile.open(backup_path, "r:gz") as tar:
|
||||
tar.extractall(temp_dir)
|
||||
|
||||
return temp_dir
|
||||
|
||||
|
||||
def compare_files(current: Path, other: Path) -> Dict:
|
||||
"""Compare two files and return differences."""
|
||||
result = {
|
||||
"current_exists": current.exists(),
|
||||
"other_exists": other.exists(),
|
||||
"same": False,
|
||||
"diff": None
|
||||
}
|
||||
|
||||
if not current.exists() and not other.exists():
|
||||
result["same"] = True
|
||||
return result
|
||||
|
||||
if not current.exists() or not other.exists():
|
||||
return result
|
||||
|
||||
# Compare content
|
||||
try:
|
||||
current_content = current.read_text()
|
||||
other_content = other.read_text()
|
||||
|
||||
if current_content == other_content:
|
||||
result["same"] = True
|
||||
return result
|
||||
|
||||
# For JSON files, do structural comparison
|
||||
if current.suffix == ".json":
|
||||
try:
|
||||
current_json = json.loads(current_content)
|
||||
other_json = json.loads(other_content)
|
||||
|
||||
# Find differences
|
||||
result["diff"] = {
|
||||
"type": "json",
|
||||
"added": [],
|
||||
"removed": [],
|
||||
"changed": []
|
||||
}
|
||||
|
||||
# Simple key comparison for top-level
|
||||
current_keys = set(current_json.keys()) if isinstance(current_json, dict) else set()
|
||||
other_keys = set(other_json.keys()) if isinstance(other_json, dict) else set()
|
||||
|
||||
result["diff"]["added"] = list(current_keys - other_keys)
|
||||
result["diff"]["removed"] = list(other_keys - current_keys)
|
||||
|
||||
# Check for changed values
|
||||
for key in current_keys & other_keys:
|
||||
if current_json.get(key) != other_json.get(key):
|
||||
result["diff"]["changed"].append(key)
|
||||
|
||||
return result
|
||||
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Text comparison
|
||||
current_lines = current_content.split("\n")
|
||||
other_lines = other_content.split("\n")
|
||||
|
||||
result["diff"] = {
|
||||
"type": "text",
|
||||
"current_lines": len(current_lines),
|
||||
"other_lines": len(other_lines),
|
||||
"line_diff": len(current_lines) - len(other_lines)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_latest_backup() -> Optional[Path]:
|
||||
"""Get the most recent backup file."""
|
||||
if not BACKUP_DIR.exists():
|
||||
return None
|
||||
|
||||
backups = list(BACKUP_DIR.glob("*.tar.gz"))
|
||||
if not backups:
|
||||
return None
|
||||
|
||||
return max(backups, key=lambda p: p.stat().st_mtime)
|
||||
|
||||
|
||||
def compare_with_backup(backup_path: Path) -> Dict[str, Dict]:
|
||||
"""Compare current config with a backup."""
|
||||
temp_dir = extract_backup(backup_path)
|
||||
|
||||
try:
|
||||
results = {}
|
||||
config_files = get_config_files()
|
||||
|
||||
for current_file in config_files:
|
||||
rel_path = current_file.relative_to(CLAUDE_DIR)
|
||||
backup_file = temp_dir / rel_path
|
||||
|
||||
results[str(rel_path)] = compare_files(current_file, backup_file)
|
||||
|
||||
# Check for files in backup that don't exist in current
|
||||
for backup_file in temp_dir.rglob("*.json"):
|
||||
rel_path = backup_file.relative_to(temp_dir)
|
||||
current_file = CLAUDE_DIR / rel_path
|
||||
|
||||
if str(rel_path) not in results:
|
||||
results[str(rel_path)] = compare_files(current_file, backup_file)
|
||||
|
||||
return results
|
||||
|
||||
finally:
|
||||
# Cleanup temp directory
|
||||
import shutil
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def format_results(results: Dict[str, Dict], json_output: bool = False) -> str:
|
||||
"""Format comparison results."""
|
||||
if json_output:
|
||||
return json.dumps(results, indent=2)
|
||||
|
||||
lines = ["\n📊 Configuration Diff\n"]
|
||||
|
||||
# Group by status
|
||||
same = []
|
||||
added = []
|
||||
removed = []
|
||||
changed = []
|
||||
|
||||
for path, info in results.items():
|
||||
if info.get("same"):
|
||||
same.append(path)
|
||||
elif info.get("current_exists") and not info.get("other_exists"):
|
||||
added.append(path)
|
||||
elif not info.get("current_exists") and info.get("other_exists"):
|
||||
removed.append(path)
|
||||
else:
|
||||
changed.append((path, info))
|
||||
|
||||
# Summary
|
||||
lines.append(f"Unchanged: {len(same)}")
|
||||
lines.append(f"Added: {len(added)}")
|
||||
lines.append(f"Removed: {len(removed)}")
|
||||
lines.append(f"Changed: {len(changed)}")
|
||||
lines.append("")
|
||||
|
||||
# Details
|
||||
if added:
|
||||
lines.append("=== Added Files ===")
|
||||
for path in added:
|
||||
lines.append(f" + {path}")
|
||||
lines.append("")
|
||||
|
||||
if removed:
|
||||
lines.append("=== Removed Files ===")
|
||||
for path in removed:
|
||||
lines.append(f" - {path}")
|
||||
lines.append("")
|
||||
|
||||
if changed:
|
||||
lines.append("=== Changed Files ===")
|
||||
for path, info in changed:
|
||||
lines.append(f" ~ {path}")
|
||||
diff = info.get("diff", {})
|
||||
if diff:
|
||||
if diff.get("type") == "json":
|
||||
if diff.get("added"):
|
||||
lines.append(f" Added keys: {', '.join(diff['added'][:5])}")
|
||||
if diff.get("removed"):
|
||||
lines.append(f" Removed keys: {', '.join(diff['removed'][:5])}")
|
||||
if diff.get("changed"):
|
||||
lines.append(f" Changed keys: {', '.join(diff['changed'][:5])}")
|
||||
elif diff.get("type") == "text":
|
||||
lines.append(f" Lines: {diff['other_lines']} -> {diff['current_lines']}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Compare configuration with backup")
|
||||
parser.add_argument("--backup", "-b", type=str,
|
||||
help="Backup file to compare with (default: latest)")
|
||||
parser.add_argument("--list", "-l", action="store_true",
|
||||
help="List available backups")
|
||||
parser.add_argument("--json", "-j", action="store_true",
|
||||
help="Output as JSON")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list:
|
||||
if not BACKUP_DIR.exists():
|
||||
print("No backups directory found.")
|
||||
return 0
|
||||
|
||||
backups = sorted(BACKUP_DIR.glob("*.tar.gz"),
|
||||
key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
if not backups:
|
||||
print("No backups found.")
|
||||
return 0
|
||||
|
||||
print("\n📦 Available Backups\n")
|
||||
for b in backups[:10]:
|
||||
mtime = datetime.fromtimestamp(b.stat().st_mtime)
|
||||
size = b.stat().st_size
|
||||
if size > 1024 * 1024:
|
||||
size_str = f"{size / 1024 / 1024:.1f}M"
|
||||
else:
|
||||
size_str = f"{size / 1024:.1f}K"
|
||||
print(f" {b.name:<40} {size_str:<8} {mtime:%Y-%m-%d %H:%M}")
|
||||
|
||||
return 0
|
||||
|
||||
# Get backup to compare
|
||||
if args.backup:
|
||||
backup_path = Path(args.backup)
|
||||
if not backup_path.exists():
|
||||
backup_path = BACKUP_DIR / args.backup
|
||||
if not backup_path.exists():
|
||||
print(f"Backup not found: {args.backup}")
|
||||
return 1
|
||||
else:
|
||||
backup_path = get_latest_backup()
|
||||
if not backup_path:
|
||||
print("No backups found. Create one with: claude-backup")
|
||||
return 1
|
||||
|
||||
print(f"Comparing with: {backup_path.name}")
|
||||
|
||||
results = compare_with_backup(backup_path)
|
||||
print(format_results(results, args.json))
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/bin/bash
|
||||
# Merge a feature branch with rebase, close PR, and cleanup
|
||||
# Usage: gitea-merge.sh [branch-name]
|
||||
# If no branch specified, uses current branch
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_URL="https://gitea-http.taildb3494.ts.net"
|
||||
REPO="will/claude-code"
|
||||
TOKEN_FILE="$HOME/.config/gitea-token"
|
||||
|
||||
if [[ ! -f "$TOKEN_FILE" ]]; then
|
||||
echo "Error: Gitea token not found at $TOKEN_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOKEN=$(cat "$TOKEN_FILE")
|
||||
|
||||
# Get branch to merge
|
||||
BRANCH="${1:-$(git rev-parse --abbrev-ref HEAD)}"
|
||||
|
||||
if [[ "$BRANCH" == "main" ]]; then
|
||||
echo "Error: Already on main, specify a feature branch" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Merging branch: $BRANCH"
|
||||
|
||||
# Find PR number for this branch
|
||||
PR_NUMBER=$(curl -s -H "Authorization: token $TOKEN" \
|
||||
"$GITEA_URL/api/v1/repos/$REPO/pulls?state=open" | \
|
||||
python3 -c "
|
||||
import sys, json
|
||||
prs = json.load(sys.stdin)
|
||||
for pr in prs:
|
||||
if pr.get('head', {}).get('ref') == '$BRANCH':
|
||||
print(pr['number'])
|
||||
break
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
# Stash any uncommitted changes
|
||||
STASHED=false
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
echo "Stashing uncommitted changes..."
|
||||
git stash
|
||||
STASHED=true
|
||||
fi
|
||||
|
||||
# Checkout main and rebase
|
||||
echo "Checking out main..."
|
||||
git checkout main
|
||||
git pull origin main
|
||||
|
||||
echo "Rebasing $BRANCH onto main..."
|
||||
git rebase "$BRANCH"
|
||||
|
||||
echo "Pushing to origin..."
|
||||
git push origin main
|
||||
|
||||
# Delete local branch
|
||||
echo "Deleting local branch..."
|
||||
git branch -d "$BRANCH" 2>/dev/null || true
|
||||
|
||||
# Delete remote branch
|
||||
echo "Deleting remote branch..."
|
||||
git push origin --delete "$BRANCH" 2>/dev/null || true
|
||||
|
||||
# Close PR if found
|
||||
if [[ -n "$PR_NUMBER" ]]; then
|
||||
echo "Closing PR #$PR_NUMBER..."
|
||||
curl -s -X PATCH \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"state":"closed"}' \
|
||||
"$GITEA_URL/api/v1/repos/$REPO/pulls/$PR_NUMBER" >/dev/null
|
||||
echo "PR #$PR_NUMBER closed"
|
||||
else
|
||||
echo "No open PR found for branch $BRANCH"
|
||||
fi
|
||||
|
||||
# Restore stashed changes
|
||||
if [[ "$STASHED" == "true" ]]; then
|
||||
echo "Restoring stashed changes..."
|
||||
git stash pop
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done! Branch $BRANCH merged to main"
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
# Create a PR in Gitea for the current branch
|
||||
# Usage: gitea-pr.sh [title] [body]
|
||||
# Runs validation before creating PR
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
GITEA_URL="https://gitea-http.taildb3494.ts.net"
|
||||
REPO="will/claude-code"
|
||||
TOKEN_FILE="$HOME/.config/gitea-token"
|
||||
|
||||
if [[ ! -f "$TOKEN_FILE" ]]; then
|
||||
echo "Error: Gitea token not found at $TOKEN_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOKEN=$(cat "$TOKEN_FILE")
|
||||
|
||||
# Get current branch
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
if [[ "$BRANCH" == "main" ]]; then
|
||||
echo "Error: Cannot create PR from main branch" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run validation
|
||||
echo "Running pre-PR validation..."
|
||||
if ! "$SCRIPT_DIR/validate-pr.sh"; then
|
||||
echo "Error: Validation failed. Fix issues before creating PR." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Default title from branch name
|
||||
TITLE="${1:-$BRANCH}"
|
||||
BODY="${2:-Auto-generated PR for $BRANCH}"
|
||||
|
||||
# Create PR via API
|
||||
RESPONSE=$(curl -s -X POST \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"title\": \"$TITLE\",
|
||||
\"body\": \"$BODY\",
|
||||
\"head\": \"$BRANCH\",
|
||||
\"base\": \"main\"
|
||||
}" \
|
||||
"$GITEA_URL/api/v1/repos/$REPO/pulls")
|
||||
|
||||
# Extract PR URL or error
|
||||
PR_URL=$(echo "$RESPONSE" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
if 'html_url' in d:
|
||||
print(d['html_url'])
|
||||
elif 'message' in d:
|
||||
print(f\"Error: {d['message']}\", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f'Unexpected response: {d}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
" 2>&1)
|
||||
|
||||
if [[ $? -eq 0 ]]; then
|
||||
echo "PR created: $PR_URL"
|
||||
else
|
||||
echo "$PR_URL" >&2
|
||||
exit 1
|
||||
fi
|
||||
Executable
+257
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Manage session templates for common workflows.
|
||||
Usage: python3 session-template.py [--list|--create NAME|--use NAME|--delete NAME]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
CLAUDE_DIR = Path.home() / ".claude"
|
||||
TEMPLATES_DIR = CLAUDE_DIR / "state" / "personal-assistant" / "templates"
|
||||
|
||||
|
||||
def ensure_templates_dir():
|
||||
"""Ensure templates directory exists."""
|
||||
TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def load_template(name: str) -> Optional[Dict]:
|
||||
"""Load a template by name."""
|
||||
template_file = TEMPLATES_DIR / f"{name}.json"
|
||||
try:
|
||||
with open(template_file) as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def save_template(name: str, template: Dict):
|
||||
"""Save a template."""
|
||||
ensure_templates_dir()
|
||||
template_file = TEMPLATES_DIR / f"{name}.json"
|
||||
with open(template_file, "w") as f:
|
||||
json.dump(template, f, indent=2)
|
||||
|
||||
|
||||
def list_templates():
|
||||
"""List available templates."""
|
||||
ensure_templates_dir()
|
||||
|
||||
templates = list(TEMPLATES_DIR.glob("*.json"))
|
||||
|
||||
if not templates:
|
||||
print("\nNo templates found. Create one with --create <name>")
|
||||
print("\nBuilt-in templates you can create:")
|
||||
print(" - daily-standup: Morning status check and planning")
|
||||
print(" - code-review: Review code changes")
|
||||
print(" - troubleshoot: Debug an issue")
|
||||
print(" - deploy: Deployment workflow")
|
||||
return
|
||||
|
||||
print(f"\n📋 Session Templates ({len(templates)})\n")
|
||||
|
||||
for t in sorted(templates):
|
||||
template = load_template(t.stem)
|
||||
if template:
|
||||
desc = template.get("description", "No description")
|
||||
category = template.get("category", "general")
|
||||
print(f" {t.stem}")
|
||||
print(f" Category: {category}")
|
||||
print(f" {desc}")
|
||||
print("")
|
||||
|
||||
|
||||
def create_template(name: str, interactive: bool = True):
|
||||
"""Create a new template."""
|
||||
ensure_templates_dir()
|
||||
|
||||
# Check for built-in templates
|
||||
builtin_templates = {
|
||||
"daily-standup": {
|
||||
"name": "daily-standup",
|
||||
"description": "Morning status check and planning",
|
||||
"category": "routine",
|
||||
"context_level": "moderate",
|
||||
"initial_commands": ["/status"],
|
||||
"checklist": [
|
||||
"Check system health",
|
||||
"Review pending items",
|
||||
"Check calendar",
|
||||
"Plan priorities"
|
||||
],
|
||||
"prompt_template": "Good morning! Let's start with a quick status check and plan the day."
|
||||
},
|
||||
"code-review": {
|
||||
"name": "code-review",
|
||||
"description": "Review code changes in a project",
|
||||
"category": "development",
|
||||
"context_level": "comprehensive",
|
||||
"initial_commands": [],
|
||||
"checklist": [
|
||||
"Understand the change scope",
|
||||
"Check for issues",
|
||||
"Verify tests",
|
||||
"Suggest improvements"
|
||||
],
|
||||
"prompt_template": "Please review the recent changes in {project}. Focus on {focus_areas}."
|
||||
},
|
||||
"troubleshoot": {
|
||||
"name": "troubleshoot",
|
||||
"description": "Debug an issue systematically",
|
||||
"category": "debugging",
|
||||
"context_level": "comprehensive",
|
||||
"initial_commands": ["/debug"],
|
||||
"checklist": [
|
||||
"Gather symptoms",
|
||||
"Check logs",
|
||||
"Identify root cause",
|
||||
"Implement fix",
|
||||
"Verify resolution"
|
||||
],
|
||||
"prompt_template": "I'm experiencing {issue}. Let's debug this systematically."
|
||||
},
|
||||
"deploy": {
|
||||
"name": "deploy",
|
||||
"description": "Deploy application to environment",
|
||||
"category": "operations",
|
||||
"context_level": "moderate",
|
||||
"initial_commands": ["/k8s:cluster-status"],
|
||||
"checklist": [
|
||||
"Verify cluster health",
|
||||
"Check application readiness",
|
||||
"Perform deployment",
|
||||
"Verify deployment",
|
||||
"Monitor for issues"
|
||||
],
|
||||
"prompt_template": "Deploy {app} to {environment}."
|
||||
}
|
||||
}
|
||||
|
||||
if name in builtin_templates:
|
||||
template = builtin_templates[name]
|
||||
save_template(name, template)
|
||||
print(f"✓ Created template from built-in: {name}")
|
||||
print(f" {template['description']}")
|
||||
return
|
||||
|
||||
if interactive:
|
||||
print(f"\nCreating new template: {name}\n")
|
||||
|
||||
template = {
|
||||
"name": name,
|
||||
"created": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
# Get details interactively
|
||||
template["description"] = input("Description: ").strip() or "No description"
|
||||
template["category"] = input("Category [general]: ").strip() or "general"
|
||||
template["context_level"] = input("Context level [moderate]: ").strip() or "moderate"
|
||||
|
||||
print("Initial commands (comma-separated, e.g., /status,/debug): ")
|
||||
commands = input("> ").strip()
|
||||
template["initial_commands"] = [c.strip() for c in commands.split(",") if c.strip()]
|
||||
|
||||
print("Checklist items (one per line, empty line to finish):")
|
||||
checklist = []
|
||||
while True:
|
||||
item = input(" - ").strip()
|
||||
if not item:
|
||||
break
|
||||
checklist.append(item)
|
||||
template["checklist"] = checklist
|
||||
|
||||
template["prompt_template"] = input("Prompt template: ").strip()
|
||||
|
||||
save_template(name, template)
|
||||
print(f"\n✓ Template '{name}' created successfully")
|
||||
else:
|
||||
# Create minimal template
|
||||
template = {
|
||||
"name": name,
|
||||
"description": "Custom template",
|
||||
"category": "custom",
|
||||
"context_level": "moderate",
|
||||
"initial_commands": [],
|
||||
"checklist": [],
|
||||
"prompt_template": "",
|
||||
"created": datetime.now().isoformat()
|
||||
}
|
||||
save_template(name, template)
|
||||
print(f"✓ Created empty template: {name}")
|
||||
print(f" Edit: {TEMPLATES_DIR / f'{name}.json'}")
|
||||
|
||||
|
||||
def use_template(name: str):
|
||||
"""Display template for use."""
|
||||
template = load_template(name)
|
||||
|
||||
if not template:
|
||||
print(f"Template '{name}' not found.")
|
||||
list_templates()
|
||||
return
|
||||
|
||||
print(f"\n📋 Template: {name}\n")
|
||||
print(f"Description: {template.get('description', 'No description')}")
|
||||
print(f"Category: {template.get('category', 'general')}")
|
||||
print(f"Context: {template.get('context_level', 'moderate')}")
|
||||
|
||||
commands = template.get("initial_commands", [])
|
||||
if commands:
|
||||
print(f"\nInitial commands:")
|
||||
for cmd in commands:
|
||||
print(f" {cmd}")
|
||||
|
||||
checklist = template.get("checklist", [])
|
||||
if checklist:
|
||||
print(f"\nChecklist:")
|
||||
for i, item in enumerate(checklist, 1):
|
||||
print(f" [ ] {item}")
|
||||
|
||||
prompt = template.get("prompt_template", "")
|
||||
if prompt:
|
||||
print(f"\nPrompt template:")
|
||||
print(f" {prompt}")
|
||||
|
||||
print("")
|
||||
|
||||
|
||||
def delete_template(name: str):
|
||||
"""Delete a template."""
|
||||
template_file = TEMPLATES_DIR / f"{name}.json"
|
||||
|
||||
if not template_file.exists():
|
||||
print(f"Template '{name}' not found.")
|
||||
return
|
||||
|
||||
template_file.unlink()
|
||||
print(f"✓ Deleted template: {name}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Manage session templates")
|
||||
parser.add_argument("--list", "-l", action="store_true", help="List templates")
|
||||
parser.add_argument("--create", "-c", type=str, help="Create a template")
|
||||
parser.add_argument("--use", "-u", type=str, help="Use a template")
|
||||
parser.add_argument("--delete", "-d", type=str, help="Delete a template")
|
||||
parser.add_argument("--non-interactive", "-n", action="store_true",
|
||||
help="Non-interactive mode for --create")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.create:
|
||||
create_template(args.create, interactive=not args.non_interactive)
|
||||
elif args.use:
|
||||
use_template(args.use)
|
||||
elif args.delete:
|
||||
delete_template(args.delete)
|
||||
else:
|
||||
list_templates()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+225
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Show information about available skills.
|
||||
Usage: python3 skill-info.py [--scripts] [name]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
CLAUDE_DIR = Path.home() / ".claude"
|
||||
SKILLS_DIR = CLAUDE_DIR / "skills"
|
||||
REGISTRY_PATH = CLAUDE_DIR / "state" / "component-registry.json"
|
||||
|
||||
|
||||
def load_registry() -> Dict:
|
||||
"""Load component registry."""
|
||||
try:
|
||||
with open(REGISTRY_PATH) as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def find_skills() -> List[Path]:
|
||||
"""Find all skill directories with SKILL.md."""
|
||||
if not SKILLS_DIR.exists():
|
||||
return []
|
||||
|
||||
return [d for d in SKILLS_DIR.iterdir()
|
||||
if d.is_dir() and (d / "SKILL.md").exists()]
|
||||
|
||||
|
||||
def parse_skill_md(path: Path) -> Dict:
|
||||
"""Parse a SKILL.md file for metadata."""
|
||||
try:
|
||||
content = path.read_text()
|
||||
|
||||
result = {
|
||||
"name": path.parent.name,
|
||||
"path": str(path.relative_to(CLAUDE_DIR)),
|
||||
"description": "",
|
||||
"allowed_tools": [],
|
||||
}
|
||||
|
||||
# Parse YAML frontmatter
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 2:
|
||||
frontmatter = parts[1]
|
||||
for line in frontmatter.strip().split("\n"):
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if key == "description":
|
||||
result["description"] = value
|
||||
elif key == "allowed-tools":
|
||||
result["allowed_tools"] = [t.strip() for t in value.split(",")]
|
||||
|
||||
# Get first paragraph as description if not in frontmatter
|
||||
if not result["description"]:
|
||||
body = content.split("---")[-1] if "---" in content else content
|
||||
lines = body.strip().split("\n\n")
|
||||
for para in lines:
|
||||
if para.strip() and not para.startswith("#"):
|
||||
result["description"] = para.strip()[:200]
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return {"name": path.parent.name, "error": str(e)}
|
||||
|
||||
|
||||
def get_skill_scripts(skill_dir: Path) -> List[str]:
|
||||
"""Get list of scripts in a skill's scripts/ directory."""
|
||||
scripts_dir = skill_dir / "scripts"
|
||||
if not scripts_dir.exists():
|
||||
return []
|
||||
|
||||
scripts = []
|
||||
for f in scripts_dir.iterdir():
|
||||
if f.is_file() and f.suffix in [".py", ".sh"]:
|
||||
scripts.append(f.name)
|
||||
return sorted(scripts)
|
||||
|
||||
|
||||
def get_skill_references(skill_dir: Path) -> List[str]:
|
||||
"""Get list of reference files in a skill's references/ directory."""
|
||||
refs_dir = skill_dir / "references"
|
||||
if not refs_dir.exists():
|
||||
return []
|
||||
|
||||
refs = []
|
||||
for f in refs_dir.iterdir():
|
||||
if f.is_file():
|
||||
refs.append(f.name)
|
||||
return sorted(refs)
|
||||
|
||||
|
||||
def list_skills(show_scripts: bool = False):
|
||||
"""List all available skills."""
|
||||
registry = load_registry()
|
||||
reg_skills = registry.get("skills", {})
|
||||
|
||||
skills = find_skills()
|
||||
|
||||
if not skills:
|
||||
print("No skills found.")
|
||||
return
|
||||
|
||||
print(f"\n🎯 Available Skills ({len(skills)})\n")
|
||||
|
||||
for skill_dir in sorted(skills):
|
||||
name = skill_dir.name
|
||||
skill_info = parse_skill_md(skill_dir / "SKILL.md")
|
||||
reg_info = reg_skills.get(name, {})
|
||||
|
||||
desc = skill_info.get("description", reg_info.get("description", "No description"))
|
||||
if len(desc) > 80:
|
||||
desc = desc[:77] + "..."
|
||||
|
||||
print(f" {name}")
|
||||
print(f" {desc}")
|
||||
|
||||
if show_scripts:
|
||||
scripts = get_skill_scripts(skill_dir)
|
||||
if scripts:
|
||||
print(f" Scripts: {', '.join(scripts)}")
|
||||
|
||||
triggers = reg_info.get("triggers", [])
|
||||
if triggers:
|
||||
trigger_str = ", ".join(triggers[:4])
|
||||
if len(triggers) > 4:
|
||||
trigger_str += f" (+{len(triggers)-4} more)"
|
||||
print(f" Triggers: {trigger_str}")
|
||||
|
||||
print("")
|
||||
|
||||
|
||||
def show_skill(name: str):
|
||||
"""Show details for a specific skill."""
|
||||
# Find matching skill
|
||||
skills = find_skills()
|
||||
matches = [s for s in skills if name.lower() in s.name.lower()]
|
||||
|
||||
if not matches:
|
||||
print(f"Skill '{name}' not found.")
|
||||
print("\nAvailable skills:")
|
||||
for s in sorted(skills):
|
||||
print(f" - {s.name}")
|
||||
return
|
||||
|
||||
if len(matches) > 1 and not any(s.name == name for s in matches):
|
||||
print(f"Multiple matches for '{name}':")
|
||||
for s in matches:
|
||||
print(f" - {s.name}")
|
||||
return
|
||||
|
||||
skill_dir = next((s for s in matches if s.name == name), matches[0])
|
||||
skill_info = parse_skill_md(skill_dir / "SKILL.md")
|
||||
|
||||
registry = load_registry()
|
||||
reg_info = registry.get("skills", {}).get(skill_dir.name, {})
|
||||
|
||||
print(f"\n🎯 Skill: {skill_dir.name}\n")
|
||||
print(f"Path: {skill_dir.relative_to(CLAUDE_DIR)}/")
|
||||
print(f"Description: {skill_info.get('description', 'No description')}")
|
||||
|
||||
# Allowed tools
|
||||
allowed = skill_info.get("allowed_tools", [])
|
||||
if allowed:
|
||||
print(f"\nAllowed Tools: {', '.join(allowed)}")
|
||||
|
||||
# Triggers
|
||||
triggers = reg_info.get("triggers", [])
|
||||
if triggers:
|
||||
print(f"\nTriggers:")
|
||||
for t in triggers:
|
||||
print(f" - {t}")
|
||||
|
||||
# Scripts
|
||||
scripts = get_skill_scripts(skill_dir)
|
||||
if scripts:
|
||||
print(f"\nScripts:")
|
||||
for s in scripts:
|
||||
script_path = skill_dir / "scripts" / s
|
||||
executable = "✓" if script_path.stat().st_mode & 0o111 else "○"
|
||||
print(f" {executable} {s}")
|
||||
|
||||
# References
|
||||
refs = get_skill_references(skill_dir)
|
||||
if refs:
|
||||
print(f"\nReferences:")
|
||||
for r in refs:
|
||||
print(f" - {r}")
|
||||
|
||||
# Registry script
|
||||
if "script" in reg_info:
|
||||
print(f"\nRegistry Script: {reg_info['script']}")
|
||||
|
||||
print("")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Show skill information")
|
||||
parser.add_argument("name", nargs="?", help="Skill name to show details")
|
||||
parser.add_argument("--scripts", "-s", action="store_true",
|
||||
help="Show scripts in listing")
|
||||
parser.add_argument("--list", "-l", action="store_true", help="List all skills")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.name and not args.list:
|
||||
show_skill(args.name)
|
||||
else:
|
||||
list_skills(args.scripts)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -85,6 +85,41 @@ else
|
||||
fail "session-export.py syntax error"
|
||||
fi
|
||||
|
||||
# Test 10: workflow-info.py
|
||||
if python3 -m py_compile "${AUTOMATION_DIR}/workflow-info.py" 2>/dev/null; then
|
||||
pass "workflow-info.py syntax valid"
|
||||
else
|
||||
fail "workflow-info.py syntax error"
|
||||
fi
|
||||
|
||||
# Test 11: skill-info.py
|
||||
if python3 -m py_compile "${AUTOMATION_DIR}/skill-info.py" 2>/dev/null; then
|
||||
pass "skill-info.py syntax valid"
|
||||
else
|
||||
fail "skill-info.py syntax error"
|
||||
fi
|
||||
|
||||
# Test 12: agent-info.py
|
||||
if python3 -m py_compile "${AUTOMATION_DIR}/agent-info.py" 2>/dev/null; then
|
||||
pass "agent-info.py syntax valid"
|
||||
else
|
||||
fail "agent-info.py syntax error"
|
||||
fi
|
||||
|
||||
# Test 13: config-diff.py
|
||||
if python3 -m py_compile "${AUTOMATION_DIR}/config-diff.py" 2>/dev/null; then
|
||||
pass "config-diff.py syntax valid"
|
||||
else
|
||||
fail "config-diff.py syntax error"
|
||||
fi
|
||||
|
||||
# Test 14: session-template.py
|
||||
if python3 -m py_compile "${AUTOMATION_DIR}/session-template.py" 2>/dev/null; then
|
||||
pass "session-template.py syntax valid"
|
||||
else
|
||||
fail "session-template.py syntax error"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Skill Scripts ==="
|
||||
|
||||
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/bin/bash
|
||||
# Validate changed files before PR creation
|
||||
# Runs: shellcheck, JSON validation, Python syntax check
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ERRORS=0
|
||||
|
||||
# Get changed files compared to main
|
||||
CHANGED_FILES=$(git diff --name-only origin/main 2>/dev/null || git diff --name-only HEAD~1)
|
||||
|
||||
echo "Validating changed files..."
|
||||
|
||||
for file in $CHANGED_FILES; do
|
||||
[[ -f "$file" ]] || continue
|
||||
|
||||
case "$file" in
|
||||
*.sh)
|
||||
if command -v shellcheck &>/dev/null; then
|
||||
if ! shellcheck -S warning "$file" 2>/dev/null; then
|
||||
echo "FAIL: shellcheck $file"
|
||||
((ERRORS++))
|
||||
else
|
||||
echo "OK: $file"
|
||||
fi
|
||||
else
|
||||
echo "SKIP: shellcheck not installed"
|
||||
fi
|
||||
;;
|
||||
*.json)
|
||||
if ! python3 -m json.tool "$file" >/dev/null 2>&1; then
|
||||
echo "FAIL: invalid JSON $file"
|
||||
((ERRORS++))
|
||||
else
|
||||
echo "OK: $file"
|
||||
fi
|
||||
;;
|
||||
*.yaml|*.yml)
|
||||
if command -v yamllint &>/dev/null; then
|
||||
if ! yamllint -d relaxed "$file" 2>/dev/null; then
|
||||
echo "FAIL: yamllint $file"
|
||||
((ERRORS++))
|
||||
else
|
||||
echo "OK: $file"
|
||||
fi
|
||||
elif python3 -c "import yaml" 2>/dev/null; then
|
||||
if ! python3 -c "import yaml; yaml.safe_load(open('$file'))" 2>/dev/null; then
|
||||
echo "FAIL: invalid YAML $file"
|
||||
((ERRORS++))
|
||||
else
|
||||
echo "OK: $file"
|
||||
fi
|
||||
else
|
||||
echo "SKIP: no YAML validator"
|
||||
fi
|
||||
;;
|
||||
*.py)
|
||||
if ! python3 -m py_compile "$file" 2>/dev/null; then
|
||||
echo "FAIL: Python syntax $file"
|
||||
((ERRORS++))
|
||||
else
|
||||
echo "OK: $file"
|
||||
fi
|
||||
;;
|
||||
*.md)
|
||||
echo "OK: $file (markdown, no validation)"
|
||||
;;
|
||||
*)
|
||||
echo "SKIP: $file (no validator)"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ $ERRORS -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "Validation failed with $ERRORS error(s)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "All validations passed"
|
||||
exit 0
|
||||
@@ -81,7 +81,7 @@ echo ""
|
||||
|
||||
# Check skills
|
||||
echo "=== Skills ==="
|
||||
for skill in gmail gcal k8s-quick-status sysadmin-health usage programmer-add-project; do
|
||||
for skill in gmail gcal gtasks k8s-quick-status sysadmin-health usage programmer-add-project morning-report stock-lookup rag-search; do
|
||||
skill_dir="${CLAUDE_DIR}/skills/${skill}"
|
||||
if [[ -f "${skill_dir}/SKILL.md" ]]; then
|
||||
pass "${skill}/SKILL.md exists"
|
||||
@@ -126,6 +126,54 @@ for file in component-registry.json autonomy-levels.json model-policy.json; do
|
||||
done
|
||||
echo ""
|
||||
|
||||
# Check hybrid format enforcement
|
||||
echo "=== Hybrid Format (md/json/yaml) ==="
|
||||
|
||||
# Agents must be .md
|
||||
non_md_agents=$(find "${CLAUDE_DIR}/agents" -type f ! -name "*.md" ! -name "README*" 2>/dev/null | wc -l)
|
||||
if [[ ${non_md_agents} -eq 0 ]]; then
|
||||
pass "All agent files are .md"
|
||||
else
|
||||
fail "Found ${non_md_agents} non-.md files in agents/"
|
||||
find "${CLAUDE_DIR}/agents" -type f ! -name "*.md" ! -name "README*" 2>/dev/null | while read f; do
|
||||
echo " - $(basename "$f")"
|
||||
done
|
||||
fi
|
||||
|
||||
# Commands must be .md
|
||||
non_md_commands=$(find "${CLAUDE_DIR}/commands" -type f ! -name "*.md" ! -name "README*" 2>/dev/null | wc -l)
|
||||
if [[ ${non_md_commands} -eq 0 ]]; then
|
||||
pass "All command files are .md"
|
||||
else
|
||||
fail "Found ${non_md_commands} non-.md files in commands/"
|
||||
find "${CLAUDE_DIR}/commands" -type f ! -name "*.md" ! -name "README*" 2>/dev/null | while read f; do
|
||||
echo " - $(basename "$f")"
|
||||
done
|
||||
fi
|
||||
|
||||
# Workflows must be .yaml
|
||||
non_yaml_workflows=$(find "${CLAUDE_DIR}/workflows" -type f ! -name "*.yaml" ! -name "*.yml" ! -name "README*" 2>/dev/null | wc -l)
|
||||
if [[ ${non_yaml_workflows} -eq 0 ]]; then
|
||||
pass "All workflow files are .yaml"
|
||||
else
|
||||
fail "Found ${non_yaml_workflows} non-.yaml files in workflows/"
|
||||
find "${CLAUDE_DIR}/workflows" -type f ! -name "*.yaml" ! -name "*.yml" ! -name "README*" 2>/dev/null | while read f; do
|
||||
echo " - $(basename "$f")"
|
||||
done
|
||||
fi
|
||||
|
||||
# State must be .json (excluding subdirectories with their own patterns)
|
||||
non_json_state=$(find "${CLAUDE_DIR}/state" -maxdepth 1 -type f ! -name "*.json" ! -name "README*" 2>/dev/null | wc -l)
|
||||
if [[ ${non_json_state} -eq 0 ]]; then
|
||||
pass "All top-level state files are .json"
|
||||
else
|
||||
fail "Found ${non_json_state} non-.json files in state/"
|
||||
find "${CLAUDE_DIR}/state" -maxdepth 1 -type f ! -name "*.json" ! -name "README*" 2>/dev/null | while read f; do
|
||||
echo " - $(basename "$f")"
|
||||
done
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Check Gmail setup
|
||||
echo "=== Gmail Integration ==="
|
||||
if [[ -d "${CLAUDE_DIR}/mcp/gmail/venv" ]]; then
|
||||
|
||||
Executable
+182
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
List and describe available workflows.
|
||||
Usage: python3 workflow-info.py [--category CAT] [name]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import yaml
|
||||
|
||||
CLAUDE_DIR = Path.home() / ".claude"
|
||||
WORKFLOWS_DIR = CLAUDE_DIR / "workflows"
|
||||
REGISTRY_PATH = CLAUDE_DIR / "state" / "component-registry.json"
|
||||
|
||||
|
||||
def load_registry() -> Dict:
|
||||
"""Load component registry."""
|
||||
try:
|
||||
with open(REGISTRY_PATH) as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def find_workflow_files() -> List[Path]:
|
||||
"""Find all workflow YAML files."""
|
||||
if not WORKFLOWS_DIR.exists():
|
||||
return []
|
||||
|
||||
files = []
|
||||
for pattern in ["*.yaml", "*.yml", "**/*.yaml", "**/*.yml"]:
|
||||
files.extend(WORKFLOWS_DIR.glob(pattern))
|
||||
|
||||
# Filter out README and other non-workflow files
|
||||
return [f for f in files if f.name not in ["README.md"]]
|
||||
|
||||
|
||||
def parse_workflow(path: Path) -> Optional[Dict]:
|
||||
"""Parse a workflow YAML file."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
content = f.read()
|
||||
# Handle YAML front matter or full YAML
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 2:
|
||||
return yaml.safe_load(parts[1])
|
||||
return yaml.safe_load(content)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_workflow_category(path: Path) -> str:
|
||||
"""Get workflow category from path."""
|
||||
rel_path = path.relative_to(WORKFLOWS_DIR)
|
||||
if len(rel_path.parts) > 1:
|
||||
return rel_path.parts[0]
|
||||
return "general"
|
||||
|
||||
|
||||
def list_workflows(category: Optional[str] = None):
|
||||
"""List all available workflows."""
|
||||
registry = load_registry()
|
||||
workflows = registry.get("workflows", {})
|
||||
|
||||
# Group by category
|
||||
categories: Dict[str, List] = {}
|
||||
|
||||
for name, info in workflows.items():
|
||||
cat = name.split("/")[0] if "/" in name else "general"
|
||||
if category and cat != category:
|
||||
continue
|
||||
|
||||
if cat not in categories:
|
||||
categories[cat] = []
|
||||
|
||||
categories[cat].append({
|
||||
"name": name,
|
||||
"description": info.get("description", "No description"),
|
||||
"triggers": info.get("triggers", [])
|
||||
})
|
||||
|
||||
if not categories:
|
||||
print("No workflows found.")
|
||||
return
|
||||
|
||||
print(f"\n📋 Available Workflows\n")
|
||||
|
||||
for cat in sorted(categories.keys()):
|
||||
print(f"=== {cat.title()} ===")
|
||||
for wf in categories[cat]:
|
||||
print(f" {wf['name']}")
|
||||
print(f" {wf['description']}")
|
||||
if wf['triggers']:
|
||||
print(f" Triggers: {', '.join(wf['triggers'][:3])}")
|
||||
print("")
|
||||
|
||||
|
||||
def show_workflow(name: str):
|
||||
"""Show details for a specific workflow."""
|
||||
registry = load_registry()
|
||||
workflows = registry.get("workflows", {})
|
||||
|
||||
# Find matching workflow
|
||||
matches = [n for n in workflows.keys() if name in n]
|
||||
|
||||
if not matches:
|
||||
print(f"Workflow '{name}' not found.")
|
||||
print("\nAvailable workflows:")
|
||||
for n in sorted(workflows.keys()):
|
||||
print(f" - {n}")
|
||||
return
|
||||
|
||||
if len(matches) > 1 and name not in matches:
|
||||
print(f"Multiple matches for '{name}':")
|
||||
for m in matches:
|
||||
print(f" - {m}")
|
||||
return
|
||||
|
||||
wf_name = name if name in matches else matches[0]
|
||||
wf_info = workflows[wf_name]
|
||||
|
||||
print(f"\n📋 Workflow: {wf_name}\n")
|
||||
print(f"Description: {wf_info.get('description', 'No description')}")
|
||||
|
||||
triggers = wf_info.get("triggers", [])
|
||||
if triggers:
|
||||
print(f"\nTriggers:")
|
||||
for t in triggers:
|
||||
print(f" - {t}")
|
||||
|
||||
# Try to find and show the actual workflow file
|
||||
possible_paths = [
|
||||
WORKFLOWS_DIR / f"{wf_name}.yaml",
|
||||
WORKFLOWS_DIR / f"{wf_name}.yml",
|
||||
WORKFLOWS_DIR / wf_name / "workflow.yaml",
|
||||
]
|
||||
|
||||
for path in possible_paths:
|
||||
if path.exists():
|
||||
wf_data = parse_workflow(path)
|
||||
if wf_data:
|
||||
print(f"\nFile: {path.relative_to(CLAUDE_DIR)}")
|
||||
|
||||
if "steps" in wf_data:
|
||||
print(f"\nSteps:")
|
||||
for i, step in enumerate(wf_data["steps"], 1):
|
||||
step_name = step.get("name", f"Step {i}")
|
||||
agent = step.get("agent", "unknown")
|
||||
print(f" {i}. {step_name} (agent: {agent})")
|
||||
|
||||
if "trigger" in wf_data:
|
||||
trigger = wf_data["trigger"]
|
||||
if isinstance(trigger, dict):
|
||||
if trigger.get("schedule"):
|
||||
print(f"\nSchedule: {trigger['schedule']}")
|
||||
if trigger.get("manual"):
|
||||
print("Manual trigger: Yes")
|
||||
break
|
||||
|
||||
print("")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="List and describe workflows")
|
||||
parser.add_argument("name", nargs="?", help="Workflow name to show details")
|
||||
parser.add_argument("--category", "-c", type=str, help="Filter by category")
|
||||
parser.add_argument("--list", "-l", action="store_true", help="List all workflows")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.name and not args.list:
|
||||
show_workflow(args.name)
|
||||
else:
|
||||
list_workflows(args.category)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -19,10 +19,16 @@ 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 |
|
||||
| `/usage` | `/stats` | View usage statistics |
|
||||
| `/external` | `/llm`, `/ext` | Toggle and use external LLM mode |
|
||||
|
||||
### Kubernetes (`/k8s:*`)
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: agent-info
|
||||
description: Show information about available agents
|
||||
aliases: [agent, agents]
|
||||
invokes: command:agent-info
|
||||
---
|
||||
|
||||
# Agent Info Command
|
||||
|
||||
Show information about available agents and their hierarchy.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/agent-info # List all agents
|
||||
/agent-info <name> # Show agent details
|
||||
/agent-info --tree # Show agent hierarchy
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
Run the agent info script:
|
||||
|
||||
```bash
|
||||
python3 ~/.claude/automation/agent-info.py [options] [name]
|
||||
```
|
||||
|
||||
## Output Includes
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| Name | Agent identifier |
|
||||
| Description | What the agent handles |
|
||||
| Model | Assigned model (opus/sonnet/haiku) |
|
||||
| Triggers | Keywords that route to this agent |
|
||||
| Supervisor | Parent agent in hierarchy |
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: diff
|
||||
description: Compare configuration with backup
|
||||
aliases: [config-diff, compare]
|
||||
invokes: command:diff
|
||||
---
|
||||
|
||||
# Diff Command
|
||||
|
||||
Compare current configuration with a backup to see what changed.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/diff # Compare with latest backup
|
||||
/diff --backup <file> # Compare with specific backup
|
||||
/diff --list # List available backups
|
||||
/diff --json # Output as JSON
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
```bash
|
||||
python3 ~/.claude/automation/config-diff.py [options]
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
Shows changes grouped by type:
|
||||
- **Added** - New files in current config
|
||||
- **Removed** - Files that existed in backup but not now
|
||||
- **Changed** - Modified files with details
|
||||
|
||||
For JSON files, shows which keys were added/removed/changed.
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: external
|
||||
description: Toggle and use external LLM mode (GPT-5.2, Gemini, etc.)
|
||||
aliases: [llm, ext, external-llm]
|
||||
---
|
||||
|
||||
# External LLM Mode
|
||||
|
||||
Route requests to external LLMs via opencode or gemini CLI.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/external # Show current status
|
||||
/external on [reason] # Enable external mode
|
||||
/external off # Disable external mode
|
||||
/external invoke <prompt> # Send prompt to default model
|
||||
/external invoke --model <model> <prompt> # Send to specific model
|
||||
/external invoke --task <task> <prompt> # Route by task type
|
||||
/external models # List available models
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
### Status
|
||||
```bash
|
||||
~/.claude/mcp/llm-router/toggle.py status
|
||||
```
|
||||
|
||||
### Toggle On/Off
|
||||
```bash
|
||||
~/.claude/mcp/llm-router/toggle.py on --reason "reason"
|
||||
~/.claude/mcp/llm-router/toggle.py off
|
||||
```
|
||||
|
||||
### Invoke
|
||||
```bash
|
||||
~/.claude/mcp/llm-router/invoke.py --model MODEL -p "prompt" [--json]
|
||||
~/.claude/mcp/llm-router/invoke.py --task TASK -p "prompt" [--json]
|
||||
```
|
||||
|
||||
## Available Models by Tier
|
||||
|
||||
### Frontier (strongest)
|
||||
| Model | Provider | Best For |
|
||||
|-------|----------|----------|
|
||||
| `github-copilot/gpt-5.2` | opencode | reasoning, fallback |
|
||||
| `github-copilot/gemini-3-pro-preview` | opencode | long context, reasoning |
|
||||
| `gemini/gemini-2.5-pro` | gemini | long context, reasoning |
|
||||
|
||||
### Mid-tier (general purpose)
|
||||
| Model | Provider | Best For |
|
||||
|-------|----------|----------|
|
||||
| `github-copilot/claude-sonnet-4.5` | opencode | general, fallback |
|
||||
| `github-copilot/gemini-3-flash-preview` | opencode | fast |
|
||||
| `zai-coding-plan/glm-4.7` | opencode | code generation |
|
||||
| `opencode/big-pickle` | opencode | general |
|
||||
| `gemini/gemini-2.5-flash` | gemini | fast |
|
||||
|
||||
### Lightweight (simple tasks)
|
||||
| Model | Provider | Best For |
|
||||
|-------|----------|----------|
|
||||
| `github-copilot/claude-haiku-4.5` | opencode | simple tasks |
|
||||
|
||||
## Task Routing
|
||||
|
||||
| Task | Routes To | Tier |
|
||||
|------|-----------|------|
|
||||
| `reasoning` | github-copilot/gpt-5.2 | frontier |
|
||||
| `code-generation` | github-copilot/gemini-3-pro-preview | frontier |
|
||||
| `long-context` | gemini/gemini-2.5-pro | frontier |
|
||||
| `fast` | github-copilot/gemini-3-flash-preview | mid-tier |
|
||||
| `general` (default) | github-copilot/claude-sonnet-4.5 | mid-tier |
|
||||
|
||||
## State Files
|
||||
|
||||
- Mode state: `~/.claude/state/external-mode.json`
|
||||
- Model policy: `~/.claude/state/model-policy.json`
|
||||
|
||||
## Examples
|
||||
|
||||
```
|
||||
/external on testing # Enable for testing
|
||||
/external invoke "Explain k8s pods" # Use default model (mid-tier)
|
||||
/external invoke --model github-copilot/gpt-5.2 "Complex analysis" # frontier
|
||||
/external invoke --task code-generation "Write a Python function" # routes to frontier
|
||||
/external invoke --task fast "Quick question" # routes to mid-tier
|
||||
/external off # Back to Claude
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: skill-info
|
||||
description: Show information about available skills
|
||||
aliases: [skill, skills-info]
|
||||
invokes: command:skill-info
|
||||
---
|
||||
|
||||
# Skill Info Command
|
||||
|
||||
Show detailed information about available skills.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/skill-info # List all skills
|
||||
/skill-info <name> # Show skill details
|
||||
/skill-info --scripts # List skills with scripts
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
Run the skill info script:
|
||||
|
||||
```bash
|
||||
python3 ~/.claude/automation/skill-info.py [options] [name]
|
||||
```
|
||||
|
||||
## Output Includes
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| Description | What the skill does |
|
||||
| Scripts | Available executable scripts |
|
||||
| Triggers | Keywords that invoke the skill |
|
||||
| References | Documentation files |
|
||||
| Allowed Tools | Tool restrictions (if any) |
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: template
|
||||
description: Manage session templates for common workflows
|
||||
aliases: [templates, session-template]
|
||||
invokes: command:template
|
||||
---
|
||||
|
||||
# Template Command
|
||||
|
||||
Create and use session templates for repeatable workflows.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/template # List all templates
|
||||
/template --use <name> # Display template for use
|
||||
/template --create <name> # Create new template
|
||||
/template --delete <name> # Delete a template
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
```bash
|
||||
python3 ~/.claude/automation/session-template.py [options]
|
||||
```
|
||||
|
||||
## Built-in Templates
|
||||
|
||||
| Name | Category | Description |
|
||||
|------|----------|-------------|
|
||||
| `daily-standup` | routine | Morning status check and planning |
|
||||
| `code-review` | development | Review code changes in a project |
|
||||
| `troubleshoot` | debugging | Debug an issue systematically |
|
||||
| `deploy` | operations | Deploy application to environment |
|
||||
|
||||
## Template Contents
|
||||
|
||||
Each template includes:
|
||||
- **Description** - What the template is for
|
||||
- **Category** - Grouping for organization
|
||||
- **Context level** - How much context to gather
|
||||
- **Initial commands** - Commands to run at start
|
||||
- **Checklist** - Steps to follow
|
||||
- **Prompt template** - Suggested starting prompt
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: workflow
|
||||
description: List and describe available workflows
|
||||
aliases: [workflows, wf]
|
||||
invokes: command:workflow
|
||||
---
|
||||
|
||||
# Workflow Command
|
||||
|
||||
List and describe available workflows.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/workflow # List all workflows
|
||||
/workflow <name> # Show workflow details
|
||||
/workflow --category <cat> # Filter by category
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
Run the workflow info script:
|
||||
|
||||
```bash
|
||||
python3 ~/.claude/automation/workflow-info.py [options] [name]
|
||||
```
|
||||
|
||||
## Categories
|
||||
|
||||
| Category | Examples |
|
||||
|----------|----------|
|
||||
| `health` | cluster-health-check, cluster-daily-summary |
|
||||
| `deploy` | deploy-app |
|
||||
| `incidents` | pod-crashloop, node-issue-response |
|
||||
| `sysadmin` | health-check, system-update |
|
||||
|
||||
## Note
|
||||
|
||||
Workflows are design documents - they guide Claude's actions but aren't
|
||||
auto-executed. Use this command to understand available procedures.
|
||||
+2
-1
@@ -26,7 +26,7 @@ Optimized for Raspberry Pi 3B+ (1GB RAM):
|
||||
|
||||
```bash
|
||||
# Run locally
|
||||
go run ./cmd/server --port 8080 --data ./data
|
||||
go run ./cmd/server --port 8080 --data ./data --claude ~/.claude
|
||||
|
||||
# Build binary
|
||||
go build -o server ./cmd/server
|
||||
@@ -72,6 +72,7 @@ kubectl apply -k deploy/
|
||||
|------|---------|-------------|
|
||||
| --port | 8080 | Server port |
|
||||
| --data | /data | Data directory for persistent state |
|
||||
| --claude | ~/.claude | Claude Code directory |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultClaudeDir(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Fatalf("UserHomeDir: %v", err)
|
||||
}
|
||||
want := filepath.Join(home, ".claude")
|
||||
got := defaultClaudeDir()
|
||||
if got != want {
|
||||
t.Fatalf("defaultClaudeDir() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -7,20 +7,31 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/will/k8s-agent-dashboard/internal/api"
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
"github.com/will/k8s-agent-dashboard/internal/store"
|
||||
)
|
||||
|
||||
//go:embed all:web
|
||||
var webFS embed.FS
|
||||
|
||||
func defaultClaudeDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "/home/will/.claude" // fallback; best-effort
|
||||
}
|
||||
return filepath.Join(home, ".claude")
|
||||
}
|
||||
|
||||
func main() {
|
||||
port := flag.String("port", "8080", "Server port")
|
||||
dataDir := flag.String("data", "/data", "Data directory for state")
|
||||
claudeDir := flag.String("claude", defaultClaudeDir(), "Claude Code directory")
|
||||
flag.Parse()
|
||||
|
||||
// Initialize store
|
||||
@@ -29,6 +40,12 @@ func main() {
|
||||
log.Fatalf("Failed to initialize store: %v", err)
|
||||
}
|
||||
|
||||
// Initialize Claude loader
|
||||
claudeLoader := claude.NewLoader(*claudeDir)
|
||||
|
||||
// Initialize event hub
|
||||
hub := claude.NewEventHub(1000)
|
||||
|
||||
// Create router
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -48,6 +65,16 @@ func main() {
|
||||
// API routes
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Get("/health", api.HealthCheck)
|
||||
|
||||
r.Route("/claude", func(r chi.Router) {
|
||||
r.Get("/health", api.GetClaudeHealth(claudeLoader))
|
||||
r.Get("/stats", api.GetClaudeStats(claudeLoader))
|
||||
r.Get("/summary", api.GetClaudeSummary(claudeLoader))
|
||||
r.Get("/inventory", api.GetClaudeInventory(claudeLoader))
|
||||
r.Get("/debug/files", api.GetClaudeDebugFiles(claudeLoader))
|
||||
r.Get("/live/backlog", api.GetClaudeLiveBacklog(claudeLoader))
|
||||
r.Get("/stream", api.GetClaudeStream(hub))
|
||||
})
|
||||
r.Get("/status", api.GetClusterStatus(s))
|
||||
r.Get("/pending", api.GetPendingActions(s))
|
||||
r.Post("/pending/{id}/approve", api.ApproveAction(s))
|
||||
@@ -78,6 +105,10 @@ func main() {
|
||||
|
||||
log.Printf("Starting server on %s", addr)
|
||||
log.Printf("Data directory: %s", *dataDir)
|
||||
log.Printf("Claude directory: %s", *claudeDir)
|
||||
|
||||
stop := make(chan struct{})
|
||||
go claude.TailHistoryFile(stop, hub, filepath.Join(*claudeDir, "history.jsonl"))
|
||||
|
||||
if err := http.ListenAndServe(addr, r); err != nil {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
</header>
|
||||
|
||||
<nav>
|
||||
<!-- General -->
|
||||
<button class="nav-btn" data-view="overview">Overview</button>
|
||||
<button class="nav-btn" data-view="usage">Usage</button>
|
||||
<button class="nav-btn" data-view="inventory">Inventory</button>
|
||||
<button class="nav-btn" data-view="debug">Debug</button>
|
||||
<button class="nav-btn" data-view="live">Live</button>
|
||||
|
||||
<!-- Existing K8s views (kept intact) -->
|
||||
<button class="nav-btn active" data-view="status">Status</button>
|
||||
<button class="nav-btn" data-view="pending">Pending <span id="pending-count" class="badge">0</span></button>
|
||||
<button class="nav-btn" data-view="history">History</button>
|
||||
@@ -23,6 +31,89 @@
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<!-- Overview View -->
|
||||
<section id="overview-view" class="view">
|
||||
<div class="card">
|
||||
<h2>Overview</h2>
|
||||
<div id="claude-overview">
|
||||
<p class="empty-state">Loading Claude overview...</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Usage View -->
|
||||
<section id="usage-view" class="view">
|
||||
<div class="card">
|
||||
<h2>Usage</h2>
|
||||
<table id="claude-usage-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Sessions</th>
|
||||
<th>Messages</th>
|
||||
<th>Tool Calls</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="4" class="empty-state">Loading usage...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Inventory View -->
|
||||
<section id="inventory-view" class="view">
|
||||
<div class="card">
|
||||
<h2>Inventory</h2>
|
||||
<div id="claude-inventory">
|
||||
<p class="empty-state">Loading inventory...</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Debug View -->
|
||||
<section id="debug-view" class="view">
|
||||
<div class="card">
|
||||
<h2>Debug</h2>
|
||||
<table id="claude-debug-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Status</th>
|
||||
<th>MTime</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="4" class="empty-state">Loading debug info...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Live View -->
|
||||
<section id="live-view" class="view">
|
||||
<div class="card">
|
||||
<h2>Live Feed</h2>
|
||||
<div class="live-header">
|
||||
<span id="claude-live-conn" class="conn-badge conn-badge-yellow">Connecting...</span>
|
||||
</div>
|
||||
<table id="claude-live-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Type</th>
|
||||
<th>Summary</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="4" class="empty-state">Waiting for events...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Status View -->
|
||||
<section id="status-view" class="view active">
|
||||
<div class="card">
|
||||
|
||||
@@ -63,6 +63,7 @@ nav {
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.5rem 2rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid var(--bg-card);
|
||||
}
|
||||
@@ -178,6 +179,47 @@ td {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
/* Claude dashboard extra badges */
|
||||
.status-ok {
|
||||
background: rgba(74, 222, 128, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-missing {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.simple-list {
|
||||
margin-left: 1.25rem;
|
||||
}
|
||||
|
||||
.simple-list li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.inventory-section + .inventory-section {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.metric {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Grid helper for Claude overview (keeps markup minimal) */
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
/* In some contexts we want a grid inside a card; remove card bottom margin in that case */
|
||||
.grid .card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.alerts-list, .pending-list, .workflows-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -328,6 +370,59 @@ footer {
|
||||
.progress-bar .fill.warning { background: var(--warning); }
|
||||
.progress-bar .fill.danger { background: var(--danger); }
|
||||
|
||||
/* Live feed styles */
|
||||
.live-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.conn-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.conn-badge-connected {
|
||||
background: rgba(74, 222, 128, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.conn-badge-error {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.conn-badge-yellow {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.raw-json {
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
overflow-x: auto;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--bg-secondary);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-sm:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
header {
|
||||
|
||||
@@ -5,12 +5,34 @@ const API_BASE = '/api';
|
||||
// State
|
||||
let currentView = 'status';
|
||||
|
||||
// Live feed state
|
||||
let pendingLiveEvents = [];
|
||||
let liveEvents = [];
|
||||
let liveEventSource = null;
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setupNavigation();
|
||||
loadAllData();
|
||||
// Refresh data every 30 seconds
|
||||
setInterval(loadAllData, 30000);
|
||||
|
||||
// Initialize live feed
|
||||
initLiveFeed();
|
||||
|
||||
// Batch render live events every 1s
|
||||
setInterval(() => {
|
||||
if (pendingLiveEvents.length > 0) {
|
||||
liveEvents = [...pendingLiveEvents, ...liveEvents];
|
||||
if (liveEvents.length > 500) {
|
||||
liveEvents = liveEvents.slice(0, 500);
|
||||
}
|
||||
pendingLiveEvents = [];
|
||||
if (currentView === 'live') {
|
||||
renderLiveEvents();
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
// Navigation
|
||||
@@ -41,10 +63,16 @@ function switchView(view) {
|
||||
async function loadAllData() {
|
||||
try {
|
||||
await Promise.all([
|
||||
// Existing k8s dashboard data
|
||||
loadClusterStatus(),
|
||||
loadPendingActions(),
|
||||
loadHistory(),
|
||||
loadWorkflows()
|
||||
loadWorkflows(),
|
||||
|
||||
// Claude dashboard data
|
||||
loadClaudeStats(),
|
||||
loadClaudeInventory(),
|
||||
loadClaudeDebugFiles()
|
||||
]);
|
||||
updateLastUpdate();
|
||||
} catch (error) {
|
||||
@@ -52,6 +80,123 @@ async function loadAllData() {
|
||||
}
|
||||
}
|
||||
|
||||
async function initLiveFeed() {
|
||||
try {
|
||||
// Load initial backlog
|
||||
const response = await fetch(`${API_BASE}/claude/live/backlog?limit=200`);
|
||||
const data = await response.json();
|
||||
liveEvents = data.events || [];
|
||||
renderLiveEvents();
|
||||
|
||||
// Setup SSE
|
||||
liveEventSource = new EventSource(`${API_BASE}/claude/stream`);
|
||||
|
||||
liveEventSource.onopen = () => {
|
||||
updateLiveConnStatus('connected');
|
||||
};
|
||||
|
||||
liveEventSource.onerror = () => {
|
||||
updateLiveConnStatus('error');
|
||||
};
|
||||
|
||||
liveEventSource.onmessage = (e) => {
|
||||
try {
|
||||
const ev = JSON.parse(e.data);
|
||||
pendingLiveEvents.push(ev);
|
||||
} catch (err) {
|
||||
console.error('Error parsing SSE event:', err);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error initializing live feed:', error);
|
||||
updateLiveConnStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateLiveConnStatus(status) {
|
||||
const el = document.getElementById('claude-live-conn');
|
||||
if (!el) return;
|
||||
|
||||
el.className = `conn-badge conn-badge-${status}`;
|
||||
el.textContent = status === 'connected' ? 'Connected' : status === 'error' ? 'Disconnected' : 'Connecting...';
|
||||
}
|
||||
|
||||
function renderLiveEvents() {
|
||||
const tbody = document.querySelector('#claude-live-table tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
if (liveEvents.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">Waiting for events...</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = liveEvents.map(ev => {
|
||||
const summary = ev.data?.summary || {};
|
||||
const summaryText = Object.values(summary).filter(Boolean).join(' ') || '-';
|
||||
const display = ev.data?.json?.display || '';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${formatDateTime(ev.ts)}</td>
|
||||
<td><span class="badge">${ev.type}</span></td>
|
||||
<td>${summaryText}</td>
|
||||
<td>
|
||||
<button class="btn-sm" onclick="toggleRawJson(this)">Show JSON</button>
|
||||
<pre class="raw-json" style="display:none">${escapeHtml(JSON.stringify(ev.data?.json || ev.data?.rawLine || {}, null, 2))}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function toggleRawJson(btn) {
|
||||
const pre = btn.nextElementSibling;
|
||||
if (pre.style.display === 'none') {
|
||||
pre.style.display = 'block';
|
||||
btn.textContent = 'Hide JSON';
|
||||
} else {
|
||||
pre.style.display = 'none';
|
||||
btn.textContent = 'Show JSON';
|
||||
}
|
||||
}
|
||||
|
||||
// Claude dashboard data loading
|
||||
async function loadClaudeStats() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/claude/stats`);
|
||||
const data = await response.json();
|
||||
renderClaudeOverview(data);
|
||||
renderClaudeUsage(data);
|
||||
} catch (error) {
|
||||
// Keep k8s dashboard working even if claude endpoints are unavailable
|
||||
console.error('Error loading Claude stats:', error);
|
||||
renderClaudeOverview(null);
|
||||
renderClaudeUsage(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadClaudeInventory() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/claude/inventory`);
|
||||
const data = await response.json();
|
||||
renderClaudeInventory(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading Claude inventory:', error);
|
||||
renderClaudeInventory(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadClaudeDebugFiles() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/claude/debug/files`);
|
||||
const data = await response.json();
|
||||
renderClaudeDebugFiles(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading Claude debug files:', error);
|
||||
renderClaudeDebugFiles(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadClusterStatus() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/status`);
|
||||
@@ -226,6 +371,151 @@ function renderWorkflows(workflows) {
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Claude dashboard rendering
|
||||
function renderClaudeOverview(stats) {
|
||||
const el = document.getElementById('claude-overview');
|
||||
if (!el) return;
|
||||
|
||||
if (!stats) {
|
||||
el.innerHTML = '<p class="empty-state">Claude stats unavailable</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const lastComputedDate = stats.lastComputedDate || stats.lastComputed || stats.lastComputedAt || null;
|
||||
|
||||
// Support both shapes: {totals:{...}} and flat {totalSessions,totalMessages,...}
|
||||
const totalSessions = (stats.totalSessions != null) ? stats.totalSessions : (stats.totals && stats.totals.sessions != null ? stats.totals.sessions : 0);
|
||||
const totalMessages = (stats.totalMessages != null) ? stats.totalMessages : (stats.totals && stats.totals.messages != null ? stats.totals.messages : 0);
|
||||
const totalToolCalls = (stats.totalToolCalls != null) ? stats.totalToolCalls : (stats.totals && (stats.totals.toolCalls != null ? stats.totals.toolCalls : (stats.totals.tools != null ? stats.totals.tools : 0)));
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>Total Sessions</h3>
|
||||
<div class="metric">${totalSessions}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Total Messages</h3>
|
||||
<div class="metric">${totalMessages}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Total Tool Calls</h3>
|
||||
<div class="metric">${totalToolCalls}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Last Computed</h3>
|
||||
<div class="metric" style="font-size: 14px; font-weight: 600;">${lastComputedDate ? formatDateTime(lastComputedDate) : 'Unknown'}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderClaudeUsage(stats) {
|
||||
const tbody = document.querySelector('#claude-usage-table tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
const daily = (stats && (stats.dailyActivity || stats.daily)) ? (stats.dailyActivity || stats.daily) : [];
|
||||
|
||||
if (!daily || daily.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">No usage data available</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = daily.map(d => {
|
||||
const sessions = (d.sessionCount != null) ? d.sessionCount : (d.sessions != null ? d.sessions : 0);
|
||||
const messages = (d.messageCount != null) ? d.messageCount : (d.messages != null ? d.messages : 0);
|
||||
const toolCalls = (d.toolCallCount != null) ? d.toolCallCount : ((d.toolCalls != null) ? d.toolCalls : ((d.tools != null) ? d.tools : 0));
|
||||
return `
|
||||
<tr>
|
||||
<td>${d.date || d.day || ''}</td>
|
||||
<td>${sessions}</td>
|
||||
<td>${messages}</td>
|
||||
<td>${toolCalls}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderClaudeInventory(inv) {
|
||||
const el = document.getElementById('claude-inventory');
|
||||
if (!el) return;
|
||||
|
||||
if (!inv) {
|
||||
el.innerHTML = '<p class="empty-state">Claude inventory unavailable</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const agents = inv.agents || [];
|
||||
const skills = inv.skills || [];
|
||||
const commands = inv.commands || [];
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="inventory-section">
|
||||
<h3>Agents (${agents.length})</h3>
|
||||
${renderSimpleList(agents.map(a => a.name || a.path || a))}
|
||||
</div>
|
||||
<div class="inventory-section">
|
||||
<h3>Skills (${skills.length})</h3>
|
||||
${renderSimpleList(skills.map(s => s.name || s.path || s))}
|
||||
</div>
|
||||
<div class="inventory-section">
|
||||
<h3>Commands (${commands.length})</h3>
|
||||
${renderSimpleList(commands.map(c => c.name || c.path || c))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderClaudeDebugFiles(debug) {
|
||||
const tbody = document.querySelector('#claude-debug-table tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
const files = (debug && (debug.files || debug.keyFiles)) ? (debug.files || debug.keyFiles) : [];
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state">No debug file info available</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = files.map(f => {
|
||||
const exists = (f.exists != null) ? f.exists : !f.missing;
|
||||
const status = (f.status || ((!exists) ? 'missing' : 'ok')).toLowerCase();
|
||||
const badgeClass = status === 'ok' ? 'status-ok' : 'status-missing';
|
||||
return `
|
||||
<tr>
|
||||
<td><code>${escapeHtml(f.name || f.path || '')}</code></td>
|
||||
<td><span class="status-badge ${badgeClass}">${status}</span></td>
|
||||
<td>${(f.mtime || f.modTime) ? formatDateTime(f.mtime || f.modTime) : ''}</td>
|
||||
<td>${f.error ? escapeHtml(f.error) : ''}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderSimpleList(items) {
|
||||
const safeItems = (items || []).filter(Boolean);
|
||||
if (safeItems.length === 0) return '<p class="empty-state">None</p>';
|
||||
return `
|
||||
<ul class="simple-list">
|
||||
${safeItems.map(i => `<li>${escapeHtml(String(i))}</li>`).join('')}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return String(value);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Actions
|
||||
async function approveAction(id) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
// ClaudeLoader is a minimal interface for Claude Ops endpoints.
|
||||
//
|
||||
// Keep it small so handlers are easy to test with fakes.
|
||||
type ClaudeLoader interface {
|
||||
ClaudeDir() string
|
||||
LoadStatsCache() (*claude.StatsCache, error)
|
||||
ListDir(name string) ([]claude.DirEntry, error)
|
||||
FileMeta(relPath string) (claude.FileMeta, error)
|
||||
PathExists(relPath string) bool
|
||||
}
|
||||
|
||||
func GetClaudeStats(loader ClaudeLoader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := loader.LoadStatsCache()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
}
|
||||
|
||||
type ClaudeSummaryResponse struct {
|
||||
Totals ClaudeSummaryTotals `json:"totals"`
|
||||
PerModel map[string]claude.ModelUsage `json:"perModel"`
|
||||
Derived ClaudeSummaryDerived `json:"derived"`
|
||||
}
|
||||
|
||||
type ClaudeSummaryTotals struct {
|
||||
TotalSessions int `json:"totalSessions"`
|
||||
TotalMessages int `json:"totalMessages"`
|
||||
}
|
||||
|
||||
type ClaudeSummaryDerived struct {
|
||||
CacheHitRatioEstimate float64 `json:"cacheHitRatioEstimate"`
|
||||
TopModelByOutputTokens string `json:"topModelByOutputTokens"`
|
||||
}
|
||||
|
||||
func GetClaudeSummary(loader ClaudeLoader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := loader.LoadStatsCache()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := ClaudeSummaryResponse{
|
||||
Totals: ClaudeSummaryTotals{
|
||||
TotalSessions: stats.TotalSessions,
|
||||
TotalMessages: stats.TotalMessages,
|
||||
},
|
||||
PerModel: stats.ModelUsage,
|
||||
}
|
||||
|
||||
var inputTokens, cacheRead, cacheCreate int
|
||||
maxOut := -1
|
||||
topModel := ""
|
||||
for model, usage := range stats.ModelUsage {
|
||||
inputTokens += usage.InputTokens
|
||||
cacheRead += usage.CacheReadInputTokens
|
||||
cacheCreate += usage.CacheCreationInputTokens
|
||||
if usage.OutputTokens > maxOut {
|
||||
maxOut = usage.OutputTokens
|
||||
topModel = model
|
||||
}
|
||||
}
|
||||
|
||||
den := float64(inputTokens + cacheRead + cacheCreate)
|
||||
ratio := 0.0
|
||||
if den > 0 {
|
||||
ratio = float64(cacheRead) / den
|
||||
}
|
||||
|
||||
resp.Derived = ClaudeSummaryDerived{
|
||||
CacheHitRatioEstimate: ratio,
|
||||
TopModelByOutputTokens: topModel,
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func GetClaudeHealth(loader ClaudeLoader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
checks := map[string]bool{}
|
||||
missing := false
|
||||
for _, rel := range []string{"stats-cache.json", "history.jsonl", filepath.Join("state", "component-registry.json")} {
|
||||
exists := loader.PathExists(rel)
|
||||
checks[rel] = exists
|
||||
if !exists {
|
||||
missing = true
|
||||
}
|
||||
}
|
||||
|
||||
status := "ok"
|
||||
if missing {
|
||||
status = "degraded"
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"status": status,
|
||||
"claudeDir": loader.ClaudeDir(),
|
||||
"fileChecks": checks,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GetClaudeInventory(loader ClaudeLoader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
agents, err := loader.ListDir("agents")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
skills, err := loader.ListDir("skills")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
commands, err := loader.ListDir("commands")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"agents": agents,
|
||||
"skills": skills,
|
||||
"commands": commands,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GetClaudeDebugFiles(loader ClaudeLoader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var metas []claude.FileMeta
|
||||
for _, rel := range []string{
|
||||
"stats-cache.json",
|
||||
"history.jsonl",
|
||||
filepath.Join("state", "component-registry.json"),
|
||||
} {
|
||||
meta, err := loader.FileMeta(rel)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
metas = append(metas, meta)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"files": metas,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
type fakeLoader struct{}
|
||||
|
||||
func (f fakeLoader) ClaudeDir() string { return "/tmp/claude" }
|
||||
|
||||
func (f fakeLoader) LoadStatsCache() (*claude.StatsCache, error) {
|
||||
return &claude.StatsCache{TotalSessions: 3}, nil
|
||||
}
|
||||
|
||||
func (f fakeLoader) ListDir(name string) ([]claude.DirEntry, error) { return nil, nil }
|
||||
|
||||
func (f fakeLoader) FileMeta(relPath string) (claude.FileMeta, error) { return claude.FileMeta{}, nil }
|
||||
|
||||
func (f fakeLoader) PathExists(relPath string) bool { return true }
|
||||
|
||||
func TestGetClaudeStats(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/claude/stats", GetClaudeStats(fakeLoader{}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/claude/stats", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClaudeSummary_IncludesDerivedCostSignals(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/claude/summary", GetClaudeSummary(fakeSummaryLoader{}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/claude/summary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Smoke-check that derived fields exist.
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"cacheHitRatioEstimate", "topModelByOutputTokens"} {
|
||||
if !jsonContainsKey(body, want) {
|
||||
t.Fatalf("expected response to include key %s, body=%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeSummaryLoader struct{ fakeLoader }
|
||||
|
||||
func (f fakeSummaryLoader) LoadStatsCache() (*claude.StatsCache, error) {
|
||||
return &claude.StatsCache{
|
||||
TotalSessions: 3,
|
||||
TotalMessages: 10,
|
||||
ModelUsage: map[string]claude.ModelUsage{
|
||||
"claude-3-5-sonnet": {
|
||||
InputTokens: 100,
|
||||
OutputTokens: 250,
|
||||
CacheReadInputTokens: 50,
|
||||
CacheCreationInputTokens: 25,
|
||||
},
|
||||
"claude-3-5-haiku": {
|
||||
InputTokens: 80,
|
||||
OutputTokens: 300,
|
||||
CacheReadInputTokens: 20,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func jsonContainsKey(body, key string) bool {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &m); err != nil {
|
||||
return false
|
||||
}
|
||||
return mapContainsKey(m, key)
|
||||
}
|
||||
|
||||
func mapContainsKey(v any, key string) bool {
|
||||
switch vv := v.(type) {
|
||||
case map[string]any:
|
||||
if _, ok := vv[key]; ok {
|
||||
return true
|
||||
}
|
||||
for _, child := range vv {
|
||||
if mapContainsKey(child, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range vv {
|
||||
if mapContainsKey(child, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
type BacklogResponse struct {
|
||||
Limit int `json:"limit"`
|
||||
Events []claude.Event `json:"events"`
|
||||
}
|
||||
|
||||
func GetClaudeLiveBacklog(loader ClaudeLoader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 200
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if n, err := strconv.Atoi(l); err == nil && n > 0 {
|
||||
limit = n
|
||||
if limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
historyPath := filepath.Join(loader.ClaudeDir(), "history.jsonl")
|
||||
lines, err := claude.TailLastNLines(historyPath, limit)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
events := make([]claude.Event, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
ev := parseHistoryLine(line)
|
||||
events = append(events, ev)
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, BacklogResponse{
|
||||
Limit: limit,
|
||||
Events: events,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func parseHistoryLine(line string) claude.Event {
|
||||
data := map[string]any{
|
||||
"rawLine": line,
|
||||
}
|
||||
|
||||
var jsonData map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &jsonData); err != nil {
|
||||
data["parseError"] = err.Error()
|
||||
} else {
|
||||
data["json"] = jsonData
|
||||
|
||||
summary := map[string]string{}
|
||||
if v, ok := jsonData["sessionId"].(string); ok {
|
||||
summary["sessionId"] = v
|
||||
}
|
||||
if v, ok := jsonData["project"].(string); ok {
|
||||
summary["project"] = v
|
||||
}
|
||||
if v, ok := jsonData["display"].(string); ok {
|
||||
summary["display"] = v
|
||||
}
|
||||
data["summary"] = summary
|
||||
}
|
||||
|
||||
return claude.Event{
|
||||
TS: time.Now(),
|
||||
Type: claude.EventTypeHistoryAppend,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
type fakeClaudeDirLoader struct{ dir string }
|
||||
|
||||
func (f fakeClaudeDirLoader) ClaudeDir() string { return f.dir }
|
||||
func (f fakeClaudeDirLoader) LoadStatsCache() (*claude.StatsCache, error) {
|
||||
return &claude.StatsCache{}, nil
|
||||
}
|
||||
func (f fakeClaudeDirLoader) ListDir(name string) ([]claude.DirEntry, error) { return nil, nil }
|
||||
func (f fakeClaudeDirLoader) FileMeta(relPath string) (claude.FileMeta, error) {
|
||||
return claude.FileMeta{}, nil
|
||||
}
|
||||
func (f fakeClaudeDirLoader) PathExists(relPath string) bool { return true }
|
||||
|
||||
func TestClaudeLiveBacklog_DefaultLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "history.jsonl")
|
||||
if err := os.WriteFile(p, []byte("{\"display\":\"/model\"}\n"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
loader := fakeClaudeDirLoader{dir: dir}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/claude/live/backlog", GetClaudeLiveBacklog(loader))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/claude/live/backlog", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !jsonContainsKey(w.Body.String(), "events") {
|
||||
t.Fatalf("expected events in response: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
// Integration-style smoke test for the Claude endpoints.
|
||||
//
|
||||
// This does NOT start a server process; it wires chi routes directly and calls
|
||||
// them via httptest.
|
||||
func TestClaudeRoutes_Smoke(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
|
||||
// Minimal filesystem layout expected by endpoints.
|
||||
mustMkdirAll(t, filepath.Join(tmp, "agents"))
|
||||
mustMkdirAll(t, filepath.Join(tmp, "skills"))
|
||||
mustMkdirAll(t, filepath.Join(tmp, "commands"))
|
||||
mustMkdirAll(t, filepath.Join(tmp, "state"))
|
||||
|
||||
// Minimal stats-cache.json required by /stats, /summary, /debug/files.
|
||||
// Keep it tiny and deterministic.
|
||||
statsCache := `{
|
||||
"totalSessions": 1,
|
||||
"totalMessages": 1,
|
||||
"modelUsage": {
|
||||
"claude-test": {
|
||||
"inputTokens": 1,
|
||||
"outputTokens": 1,
|
||||
"cacheReadInputTokens": 0,
|
||||
"cacheCreationInputTokens": 0
|
||||
}
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(tmp, "stats-cache.json"), []byte(statsCache), 0o600); err != nil {
|
||||
t.Fatalf("write stats-cache.json: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmp, "history.jsonl"), []byte("{}\n"), 0o600); err != nil {
|
||||
t.Fatalf("write history.jsonl: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmp, "state", "component-registry.json"), []byte("{}"), 0o600); err != nil {
|
||||
t.Fatalf("write state/component-registry.json: %v", err)
|
||||
}
|
||||
|
||||
loader := claude.NewLoader(tmp)
|
||||
|
||||
r := chi.NewRouter()
|
||||
// Mirror the /api/claude routes from cmd/server/main.go.
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Route("/claude", func(r chi.Router) {
|
||||
r.Get("/health", GetClaudeHealth(loader))
|
||||
r.Get("/stats", GetClaudeStats(loader))
|
||||
r.Get("/summary", GetClaudeSummary(loader))
|
||||
r.Get("/inventory", GetClaudeInventory(loader))
|
||||
r.Get("/debug/files", GetClaudeDebugFiles(loader))
|
||||
})
|
||||
})
|
||||
|
||||
for _, path := range []string{
|
||||
"/api/claude/health",
|
||||
"/api/claude/stats",
|
||||
"/api/claude/inventory",
|
||||
"/api/claude/debug/files",
|
||||
"/api/claude/summary",
|
||||
} {
|
||||
path := path
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s status=%d body=%s", path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustMkdirAll(t *testing.T, p string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
func GetClaudeStream(hub *claude.EventHub) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ch, cancel := hub.Subscribe()
|
||||
defer cancel()
|
||||
|
||||
notify := r.Context().Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case ev, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "event: %s\n", ev.Type)
|
||||
fmt.Fprintf(w, "id: %d\n", ev.ID)
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
flusher.Flush()
|
||||
|
||||
case <-notify:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
func TestClaudeStream_SendsEvent(t *testing.T) {
|
||||
hub := claude.NewEventHub(10)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/claude/stream", GetClaudeStream(hub))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/claude/stream", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
hub.Publish(claude.Event{Type: claude.EventTypeServerNotice, Data: map[string]any{"msg": "hi"}})
|
||||
}()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
|
||||
t.Fatalf("content-type=%q", ct)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "event:") || !strings.Contains(w.Body.String(), "data:") {
|
||||
t.Fatalf("body=%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type EventHub struct {
|
||||
mu sync.RWMutex
|
||||
buffer []Event
|
||||
nextID int64
|
||||
subscribers []chan Event
|
||||
bufferSize int
|
||||
}
|
||||
|
||||
func NewEventHub(bufferSize int) *EventHub {
|
||||
return &EventHub{
|
||||
buffer: make([]Event, 0, bufferSize),
|
||||
subscribers: make([]chan Event, 0),
|
||||
bufferSize: bufferSize,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *EventHub) Publish(ev Event) Event {
|
||||
if ev.ID == 0 {
|
||||
ev.ID = atomic.AddInt64(&h.nextID, 1)
|
||||
}
|
||||
if ev.TS.IsZero() {
|
||||
ev.TS = time.Now()
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if len(h.buffer) >= h.bufferSize {
|
||||
h.buffer = h.buffer[1:]
|
||||
}
|
||||
h.buffer = append(h.buffer, ev)
|
||||
|
||||
for _, ch := range h.subscribers {
|
||||
select {
|
||||
case ch <- ev:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return ev
|
||||
}
|
||||
|
||||
func (h *EventHub) Subscribe() (chan Event, func()) {
|
||||
ch := make(chan Event, 10)
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
h.subscribers = append(h.subscribers, ch)
|
||||
|
||||
cancel := func() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for i, c := range h.subscribers {
|
||||
if c == ch {
|
||||
h.subscribers = append(h.subscribers[:i], h.subscribers[i+1:]...)
|
||||
close(ch)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ch, cancel
|
||||
}
|
||||
|
||||
func (h *EventHub) ReplaySince(lastID int64) []Event {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
var result []Event
|
||||
for _, ev := range h.buffer {
|
||||
if ev.ID > lastID {
|
||||
result = append(result, ev)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEventHub_PublishSubscribe(t *testing.T) {
|
||||
hub := NewEventHub(10)
|
||||
ch, cancel := hub.Subscribe()
|
||||
defer cancel()
|
||||
|
||||
hub.Publish(Event{TS: time.Unix(1, 0), Type: EventTypeServerNotice, Data: map[string]any{"msg": "hi"}})
|
||||
|
||||
select {
|
||||
case ev := <-ch:
|
||||
if ev.Type != EventTypeServerNotice {
|
||||
t.Fatalf("type=%s", ev.Type)
|
||||
}
|
||||
if ev.ID == 0 {
|
||||
t.Fatalf("expected id to be assigned")
|
||||
}
|
||||
default:
|
||||
t.Fatalf("expected event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventHub_ReplaySince(t *testing.T) {
|
||||
hub := NewEventHub(3)
|
||||
hub.Publish(Event{TS: time.Unix(1, 0), Type: EventTypeServerNotice}) // id 1
|
||||
hub.Publish(Event{TS: time.Unix(2, 0), Type: EventTypeServerNotice}) // id 2
|
||||
hub.Publish(Event{TS: time.Unix(3, 0), Type: EventTypeServerNotice}) // id 3
|
||||
|
||||
got := hub.ReplaySince(1)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len=%d", len(got))
|
||||
}
|
||||
if got[0].ID != 2 || got[1].ID != 3 {
|
||||
t.Fatalf("ids=%d,%d", got[0].ID, got[1].ID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package claude
|
||||
|
||||
import "time"
|
||||
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventTypeHistoryAppend EventType = "history.append"
|
||||
EventTypeFileChanged EventType = "file.changed"
|
||||
EventTypeServerNotice EventType = "server.notice"
|
||||
EventTypeServerError EventType = "server.error"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID int64 `json:"id"`
|
||||
TS time.Time `json:"ts"`
|
||||
Type EventType `json:"type"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package claude
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEventTypesCompile(t *testing.T) {
|
||||
_ = Event{}
|
||||
_ = EventTypeHistoryAppend
|
||||
_ = EventTypeFileChanged
|
||||
_ = EventTypeServerNotice
|
||||
_ = EventTypeServerError
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TailHistoryFile(stop <-chan struct{}, hub *EventHub, path string) {
|
||||
var offset int64
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
stat, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
hub.Publish(Event{
|
||||
TS: time.Now(),
|
||||
Type: EventTypeServerError,
|
||||
Data: map[string]any{"error": err.Error()},
|
||||
})
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
size := stat.Size()
|
||||
if size > offset {
|
||||
if err := processNewBytes(path, offset, size, hub); err != nil {
|
||||
hub.Publish(Event{
|
||||
TS: time.Now(),
|
||||
Type: EventTypeServerError,
|
||||
Data: map[string]any{"error": err.Error()},
|
||||
})
|
||||
}
|
||||
offset = size
|
||||
} else if size < offset {
|
||||
offset = 0
|
||||
hub.Publish(Event{
|
||||
TS: time.Now(),
|
||||
Type: EventTypeServerNotice,
|
||||
Data: map[string]any{"msg": "file truncated, resetting offset"},
|
||||
})
|
||||
}
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func processNewBytes(path string, oldSize, newSize int64, hub *EventHub) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.Seek(oldSize, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"rawLine": line,
|
||||
}
|
||||
|
||||
var jsonData map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &jsonData); err != nil {
|
||||
data["parseError"] = err.Error()
|
||||
} else {
|
||||
data["json"] = jsonData
|
||||
|
||||
summary := map[string]string{}
|
||||
if v, ok := jsonData["sessionId"].(string); ok {
|
||||
summary["sessionId"] = v
|
||||
}
|
||||
if v, ok := jsonData["project"].(string); ok {
|
||||
summary["project"] = v
|
||||
}
|
||||
if v, ok := jsonData["display"].(string); ok {
|
||||
summary["display"] = v
|
||||
}
|
||||
data["summary"] = summary
|
||||
}
|
||||
|
||||
hub.Publish(Event{
|
||||
TS: time.Now(),
|
||||
Type: EventTypeHistoryAppend,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
return scanner.Err()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHistoryTailer_EmitsOnAppend(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "history.jsonl")
|
||||
if err := os.WriteFile(p, []byte(""), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
hub := NewEventHub(10)
|
||||
ch, cancel := hub.Subscribe()
|
||||
defer cancel()
|
||||
|
||||
stop := make(chan struct{})
|
||||
go TailHistoryFile(stop, hub, p)
|
||||
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
|
||||
if err := os.WriteFile(p, []byte("{\"display\":\"/status\"}\n"), 0o600); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case ev := <-ch:
|
||||
if ev.Type != EventTypeHistoryAppend {
|
||||
t.Fatalf("type=%s", ev.Type)
|
||||
}
|
||||
case <-time.After(700 * time.Millisecond):
|
||||
t.Fatalf("timed out waiting for event")
|
||||
}
|
||||
|
||||
close(stop)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Loader reads Claude Code state files from a local claude directory (typically ~/.claude).
|
||||
//
|
||||
// Keep this minimal for now; more helpers (e.g. ListDir / FileInfo) can be added later.
|
||||
type Loader struct {
|
||||
claudeDir string
|
||||
}
|
||||
|
||||
type DirEntry struct {
|
||||
Name string `json:"name"`
|
||||
IsDir bool `json:"isDir"`
|
||||
}
|
||||
|
||||
type FileMeta struct {
|
||||
Path string `json:"path"`
|
||||
Exists bool `json:"exists"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime string `json:"modTime"`
|
||||
}
|
||||
|
||||
func NewLoader(claudeDir string) *Loader {
|
||||
return &Loader{claudeDir: claudeDir}
|
||||
}
|
||||
|
||||
func (l *Loader) ClaudeDir() string { return l.claudeDir }
|
||||
|
||||
func (l *Loader) LoadStatsCache() (*StatsCache, error) {
|
||||
if l.claudeDir == "" {
|
||||
return nil, fmt.Errorf("claude dir is empty")
|
||||
}
|
||||
|
||||
p := filepath.Join(l.claudeDir, "stats-cache.json")
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read stats cache %q: %w", p, err)
|
||||
}
|
||||
|
||||
var stats StatsCache
|
||||
if err := json.Unmarshal(b, &stats); err != nil {
|
||||
return nil, fmt.Errorf("parse stats cache %q: %w", p, err)
|
||||
}
|
||||
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
func (l *Loader) ListDir(name string) ([]DirEntry, error) {
|
||||
if l.claudeDir == "" {
|
||||
return nil, fmt.Errorf("claude dir is empty")
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(filepath.Join(l.claudeDir, name))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read dir %q: %w", name, err)
|
||||
}
|
||||
|
||||
out := make([]DirEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, DirEntry{Name: e.Name(), IsDir: e.IsDir()})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (l *Loader) PathExists(relPath string) bool {
|
||||
if l.claudeDir == "" {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(filepath.Join(l.claudeDir, relPath))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (l *Loader) FileMeta(relPath string) (FileMeta, error) {
|
||||
if l.claudeDir == "" {
|
||||
return FileMeta{}, fmt.Errorf("claude dir is empty")
|
||||
}
|
||||
|
||||
p := filepath.Join(l.claudeDir, relPath)
|
||||
st, err := os.Stat(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return FileMeta{Path: relPath, Exists: false}, nil
|
||||
}
|
||||
return FileMeta{}, fmt.Errorf("stat %q: %w", p, err)
|
||||
}
|
||||
|
||||
return FileMeta{
|
||||
Path: relPath,
|
||||
Exists: true,
|
||||
Size: st.Size(),
|
||||
ModTime: st.ModTime().UTC().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadStatsCache(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "stats-cache.json")
|
||||
err := os.WriteFile(p, []byte(`{"version":1,"lastComputedDate":"2025-12-31","totalSessions":1,"totalMessages":2}`), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
loader := NewLoader(dir)
|
||||
stats, err := loader.LoadStatsCache()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadStatsCache: %v", err)
|
||||
}
|
||||
if stats.TotalSessions != 1 {
|
||||
t.Fatalf("TotalSessions=%d", stats.TotalSessions)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package claude
|
||||
|
||||
type DailyActivity struct {
|
||||
Date string `json:"date"`
|
||||
MessageCount int `json:"messageCount"`
|
||||
SessionCount int `json:"sessionCount"`
|
||||
ToolCallCount int `json:"toolCallCount"`
|
||||
}
|
||||
|
||||
type DailyModelTokens struct {
|
||||
Date string `json:"date"`
|
||||
TokensByModel map[string]int `json:"tokensByModel"`
|
||||
}
|
||||
|
||||
type ModelUsage struct {
|
||||
InputTokens int `json:"inputTokens"`
|
||||
OutputTokens int `json:"outputTokens"`
|
||||
CacheReadInputTokens int `json:"cacheReadInputTokens"`
|
||||
CacheCreationInputTokens int `json:"cacheCreationInputTokens"`
|
||||
WebSearchRequests int `json:"webSearchRequests"`
|
||||
CostUSD float64 `json:"costUSD"`
|
||||
ContextWindow int `json:"contextWindow"`
|
||||
}
|
||||
|
||||
type StatsCache struct {
|
||||
Version int `json:"version"`
|
||||
LastComputedDate string `json:"lastComputedDate"`
|
||||
DailyActivity []DailyActivity `json:"dailyActivity"`
|
||||
DailyModelTokens []DailyModelTokens `json:"dailyModelTokens"`
|
||||
ModelUsage map[string]ModelUsage `json:"modelUsage"`
|
||||
TotalSessions int `json:"totalSessions"`
|
||||
TotalMessages int `json:"totalMessages"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package claude
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestModelTypesCompile(t *testing.T) {
|
||||
_ = StatsCache{}
|
||||
_ = DailyActivity{}
|
||||
_ = ModelUsage{}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func TailLastNLines(path string, n int) ([]string, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(content), "\n")
|
||||
|
||||
var result []string
|
||||
for i := len(lines) - 1; i >= 0 && len(result) < n; i-- {
|
||||
if lines[i] != "" {
|
||||
result = append(result, lines[i])
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTailLastNLines_NewestFirst(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "history.jsonl")
|
||||
|
||||
var b strings.Builder
|
||||
for i := 1; i <= 5; i++ {
|
||||
b.WriteString("line")
|
||||
b.WriteString([]string{"1", "2", "3", "4", "5"}[i-1])
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(b.String()), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
lines, err := TailLastNLines(p, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("TailLastNLines: %v", err)
|
||||
}
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("len=%d", len(lines))
|
||||
}
|
||||
if lines[0] != "line5" || lines[1] != "line4" {
|
||||
t.Fatalf("got=%v", lines)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
go test ./...
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"plan": "2026-01-05-workstation-monitoring-design.md",
|
||||
"status": "COMPLETE",
|
||||
"completed_at": "2026-01-05T14:09:00Z",
|
||||
"implementation": {
|
||||
"node_exporter": "installed and running (v1.10.2-1)",
|
||||
"scrape_config": "deployed (workstation-scrape)",
|
||||
"prometheus_rule": "deployed (workstation-alerts, 12 rules)",
|
||||
"prometheus_target": "UP and scraping",
|
||||
"git_commit": "9d17ac8",
|
||||
"network_solution": "Tailscale (100.90.159.78:9100)"
|
||||
},
|
||||
"verification": {
|
||||
"all_success_criteria_met": true,
|
||||
"verified_at": "2026-01-05T14:09:19Z"
|
||||
}
|
||||
}
|
||||
@@ -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,270 @@
|
||||
# Guardrail Hooks Design
|
||||
|
||||
**Date:** 2025-01-06
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
PreToolUse guardrail hooks that prevent dangerous operations by intercepting Bash, Write, and Edit tool calls before execution.
|
||||
|
||||
### Goals
|
||||
|
||||
- Prevent catastrophic operations (destructive file ops, dangerous system commands, infrastructure mistakes)
|
||||
- Contextual response: hard block for severe threats, confirmation for moderate risks
|
||||
- Path-aware: operations inside projects are more permissive than outside
|
||||
- Auditable: log all interventions for review
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Git command guardrails (future consideration)
|
||||
- Rate limiting or resource protection
|
||||
- Workflow compliance enforcement
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
~/.claude/
|
||||
├── hooks/
|
||||
│ └── hooks.json # Hook registration (add PreToolUse)
|
||||
├── hooks/scripts/
|
||||
│ ├── guardrail.py # Main PreToolUse logic
|
||||
│ └── guardrail-confirm.py # Adds operation to session allowlist
|
||||
├── state/
|
||||
│ ├── guardrails.json # Rules configuration
|
||||
│ └── guardrail-session.json # Session allowlist (cleared on session end)
|
||||
└── logs/
|
||||
└── guardrail.jsonl # Audit log (blocked/confirmed only)
|
||||
```
|
||||
|
||||
### Flow
|
||||
|
||||
1. Claude invokes Bash/Write/Edit
|
||||
2. `guardrail.py` runs via PreToolUse hook
|
||||
3. Check session allowlist - if present, allow
|
||||
4. Evaluate rules + path context - decide action
|
||||
5. Allow: return `{"decision": "allow"}`
|
||||
6. Block/Confirm: log, return `{"decision": "block", "reason": "..."}`
|
||||
7. For confirm: user approves, Claude calls `guardrail-confirm.py`, retries
|
||||
|
||||
## Configuration
|
||||
|
||||
**Location:** `~/.claude/state/guardrails.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"safe_paths": [
|
||||
"~/.claude",
|
||||
"~/projects"
|
||||
],
|
||||
"blocked_paths": [
|
||||
"/etc", "/usr", "/var", "/boot", "/sys", "/proc",
|
||||
"~/.ssh", "~/.gnupg", "~/.aws"
|
||||
],
|
||||
"rules": {
|
||||
"bash": [
|
||||
{"pattern": "rm -rf /", "action": "block"},
|
||||
{"pattern": "rm -rf ~", "action": "block"},
|
||||
{"pattern": "rm -rf \\*", "action": "block"},
|
||||
{"pattern": "chmod -R 777", "action": "block"},
|
||||
{"pattern": ":(){ :|:& };:", "action": "block"},
|
||||
{"pattern": "mkfs\\.", "action": "block"},
|
||||
{"pattern": "dd if=.* of=/dev/", "action": "block"},
|
||||
{"pattern": "> /dev/sda", "action": "block"},
|
||||
{"pattern": "shutdown", "action": "confirm"},
|
||||
{"pattern": "reboot", "action": "confirm"},
|
||||
{"pattern": "systemctl (stop|disable|mask)", "action": "confirm"},
|
||||
{"pattern": "rm ", "action": "confirm", "outside_safe_paths": true},
|
||||
{"pattern": "kubectl delete", "action": "confirm"},
|
||||
{"pattern": "docker rm", "action": "confirm"}
|
||||
],
|
||||
"write": [
|
||||
{"path_patterns": ["blocked_paths"], "action": "block"},
|
||||
{"path_patterns": ["outside_safe_paths"], "action": "confirm"}
|
||||
],
|
||||
"edit": [
|
||||
{"path_patterns": ["blocked_paths"], "action": "block"},
|
||||
{"path_patterns": ["outside_safe_paths"], "action": "confirm"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key Concepts
|
||||
|
||||
- `pattern`: regex matched against command/path
|
||||
- `action`: `block` (hard stop) or `confirm` (require approval)
|
||||
- `outside_safe_paths`: rule only applies when target is outside safe directories
|
||||
- Bash rules check command string; Write/Edit rules check file path
|
||||
|
||||
## Safe Paths Logic
|
||||
|
||||
**Evaluation order (first match wins):**
|
||||
|
||||
1. **Blocked paths check** - Is target in `blocked_paths`?
|
||||
- Yes: apply block/confirm regardless of other factors
|
||||
- Protects system-critical locations absolutely
|
||||
|
||||
2. **Explicit allowlist** - Is target under a `safe_paths` entry?
|
||||
- Yes: path is safe
|
||||
- Supports glob patterns (`~/projects/*`)
|
||||
|
||||
3. **Git-aware detection** - Is target inside a git repository?
|
||||
- Walk up from target path looking for `.git` directory
|
||||
- Found: treat as safe (it's an intentional project)
|
||||
|
||||
4. **Default** - Path is outside safe areas
|
||||
- Operations here trigger `outside_safe_paths` rules
|
||||
|
||||
### Path Normalization
|
||||
|
||||
- Expand `~` to actual home directory
|
||||
- Resolve symlinks to real paths
|
||||
- Handle relative paths by resolving against CWD
|
||||
|
||||
### Examples
|
||||
|
||||
| Target Path | Result | Reason |
|
||||
|-------------|--------|--------|
|
||||
| `/etc/hosts` | blocked/confirm | In `blocked_paths` |
|
||||
| `~/.ssh/config` | blocked/confirm | In `blocked_paths` |
|
||||
| `~/.claude/hooks/test.py` | safe | In `safe_paths` |
|
||||
| `~/projects/myapp/src/main.py` | safe | In `safe_paths` |
|
||||
| `~/random-repo/file.txt` | safe | Git repo detected |
|
||||
| `~/Downloads/file.txt` | outside | No match, triggers rules |
|
||||
|
||||
**Edge case:** If a git repo exists under a blocked path (unlikely), blocked path wins.
|
||||
|
||||
## Hook Implementation
|
||||
|
||||
### Registration
|
||||
|
||||
Add to `~/.claude/hooks/hooks.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash|Write|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.claude/hooks/scripts/guardrail.py",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Hook Input (stdin)
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {
|
||||
"command": "rm -rf ~/Downloads/old-project"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Hook Output (stdout)
|
||||
|
||||
Allow:
|
||||
```json
|
||||
{"decision": "allow"}
|
||||
```
|
||||
|
||||
Block:
|
||||
```json
|
||||
{
|
||||
"decision": "block",
|
||||
"reason": "GUARDRAIL BLOCKED: 'rm' outside safe paths.\nTarget: ~/Downloads/old-project\nRule: rm_outside_safe\n\nTo proceed, confirm with user then run:\npython ~/.claude/hooks/scripts/guardrail-confirm.py 'rm -rf ~/Downloads/old-project'"
|
||||
}
|
||||
```
|
||||
|
||||
## Confirmation Flow
|
||||
|
||||
1. **Guardrail blocks** with detailed message including confirm command
|
||||
2. **Claude reports** to user, asks for confirmation
|
||||
3. **User confirms** in conversation
|
||||
4. **Claude runs confirm script:**
|
||||
```bash
|
||||
python ~/.claude/hooks/scripts/guardrail-confirm.py "rm -rf ~/Downloads/old-project"
|
||||
```
|
||||
5. **Confirm script** adds to session allowlist
|
||||
6. **Claude retries** the original command
|
||||
7. **Guardrail checks allowlist** - finds match - allows
|
||||
|
||||
### Session Allowlist
|
||||
|
||||
**Location:** `~/.claude/state/guardrail-session.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"confirmed": [
|
||||
{"tool": "Bash", "operation": "rm -rf ~/Downloads/old-project", "ts": "2025-01-06T10:24:00"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Cleanup:** `session-end.sh` clears this file so confirmations don't persist across sessions.
|
||||
|
||||
**Matching:** Exact match on tool + operation string.
|
||||
|
||||
## Logging
|
||||
|
||||
**Location:** `~/.claude/logs/guardrail.jsonl`
|
||||
|
||||
**Format:** JSON Lines (append-only)
|
||||
|
||||
**Logged events:**
|
||||
- Every `block` action
|
||||
- Every `confirm` action (required approval)
|
||||
- When confirmed operation is allowed
|
||||
|
||||
### Log Entry Structure
|
||||
|
||||
```json
|
||||
{"ts": "2025-01-06T10:23:45", "tool": "Bash", "operation": "rm -rf /", "action": "block", "rule": "rm_rf_root", "path_context": "n/a"}
|
||||
{"ts": "2025-01-06T10:24:01", "tool": "Bash", "operation": "rm ~/Downloads/old", "action": "confirm_required", "rule": "rm_outside_safe", "path_context": "outside"}
|
||||
{"ts": "2025-01-06T10:24:30", "tool": "Bash", "operation": "rm ~/Downloads/old", "action": "confirmed_allow", "rule": "session_allowlist"}
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
- `ts`: ISO 8601 timestamp
|
||||
- `tool`: Bash, Write, or Edit
|
||||
- `operation`: Command or file path
|
||||
- `action`: `block`, `confirm_required`, or `confirmed_allow`
|
||||
- `rule`: Which rule triggered
|
||||
- `path_context`: `safe`, `outside`, `blocked`, or `n/a`
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Create `state/guardrails.json` with starter rules
|
||||
- [ ] Create `hooks/scripts/guardrail.py` main logic
|
||||
- [ ] Create `hooks/scripts/guardrail-confirm.py` confirm helper
|
||||
- [ ] Modify `hooks/hooks.json` to add PreToolUse registration
|
||||
- [ ] Modify `hooks/scripts/session-end.sh` to clear session allowlist
|
||||
- [ ] Create `logs/` directory
|
||||
- [ ] Test: block scenario (catastrophic command)
|
||||
- [ ] Test: confirm scenario (rm outside safe paths)
|
||||
- [ ] Test: allow scenario (operation in safe path)
|
||||
- [ ] Test: git-aware detection
|
||||
|
||||
## Starter Rules
|
||||
|
||||
| Category | Pattern | Action |
|
||||
|----------|---------|--------|
|
||||
| Catastrophic | `rm -rf /`, `rm -rf ~`, `mkfs.*`, `dd.*of=/dev/` | block |
|
||||
| Fork bomb | `:(){ :\|:& };:` | block |
|
||||
| Dangerous chmod | `chmod -R 777` | block |
|
||||
| System commands | `shutdown`, `reboot` | confirm |
|
||||
| Service control | `systemctl (stop\|disable\|mask)` | confirm |
|
||||
| Destructive outside safe | `rm ` (outside safe paths) | confirm |
|
||||
| K8s destructive | `kubectl delete` | confirm |
|
||||
| Docker destructive | `docker rm`, `docker system prune` | confirm |
|
||||
| Write to blocked paths | Any Write/Edit to `/etc`, `~/.ssh`, etc. | block |
|
||||
| Write outside safe | Any Write/Edit outside safe paths | confirm |
|
||||
@@ -0,0 +1,388 @@
|
||||
# Agentic RAG Design
|
||||
|
||||
**Date:** 2025-01-21
|
||||
**Status:** Ready for implementation
|
||||
**Category:** Agent memory / Knowledge retrieval
|
||||
|
||||
## Overview
|
||||
|
||||
Add semantic search to the existing Claude agent system, enabling multi-source reasoning that combines personal context (state files, memory, decisions) with external documentation.
|
||||
|
||||
### Goals
|
||||
|
||||
- Retrieve relevant past decisions and preferences when answering questions
|
||||
- Search external docs (k0s, ArgoCD, Prometheus, etc.) for technical reference
|
||||
- Cross-reference personal context with official documentation
|
||||
- Support iterative query refinement (agentic behavior)
|
||||
|
||||
### Non-Goals (Future Considerations)
|
||||
|
||||
Deferred to `future-considerations.json`:
|
||||
|
||||
- **fc-043**: Auto-sync on tool version change
|
||||
- **fc-044**: Broad doc indexing (hundreds of sources)
|
||||
- **fc-045**: K8s deployment
|
||||
- **fc-046**: Query caching
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
User question
|
||||
│
|
||||
▼
|
||||
Personal Assistant (existing)
|
||||
│
|
||||
├── Decides if RAG would help
|
||||
│
|
||||
▼
|
||||
rag-search skill (new)
|
||||
│
|
||||
├── Query embedding
|
||||
├── Vector similarity search
|
||||
├── Return ranked chunks with metadata
|
||||
│
|
||||
▼
|
||||
Claude reasons over results
|
||||
│
|
||||
├── Good enough? → Answer
|
||||
└── Need more? → Reformulate, search again
|
||||
```
|
||||
|
||||
### Two Indexes
|
||||
|
||||
| Index | Contents | Update Frequency |
|
||||
|-------|----------|------------------|
|
||||
| **personal** | `~/.claude/state/` files, memory, decisions, preferences | Daily |
|
||||
| **docs** | External documentation (k0s, ArgoCD, etc.) | Daily |
|
||||
|
||||
### Why Two Indexes
|
||||
|
||||
- Different update frequencies
|
||||
- Different retrieval strategies (personal may weight recency)
|
||||
- Can query one or both depending on the question
|
||||
|
||||
## Components
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ rag-search skill │
|
||||
│ (Claude invokes this) │
|
||||
└─────────────────────┬───────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
▼ ▼
|
||||
┌───────────────────┐ ┌───────────────────┐
|
||||
│ Personal Index │ │ Docs Index │
|
||||
│ │ │ │
|
||||
│ ~/.claude/state/* │ │ External docs │
|
||||
│ memory/*.json │ │ (k0s, ArgoCD...) │
|
||||
│ kb.json │ │ │
|
||||
└────────┬──────────┘ └────────┬──────────┘
|
||||
│ │
|
||||
└──────────┬──────────────┘
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ Vector Store │
|
||||
│ (ChromaDB) │
|
||||
│ │
|
||||
│ Collections: │
|
||||
│ - personal │
|
||||
│ - docs │
|
||||
└────────┬──────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ Embedding Model │
|
||||
│ (sentence- │
|
||||
│ transformers) │
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
### Stack
|
||||
|
||||
| Component | Choice | Notes |
|
||||
|-----------|--------|-------|
|
||||
| Vector store | ChromaDB | Pure Python, no external deps |
|
||||
| Embeddings | sentence-transformers (all-MiniLM-L6-v2) | Runs on arm64, ~90MB |
|
||||
| Storage | `~/.claude/data/rag-search/` | Local to workstation |
|
||||
|
||||
## Skill Structure
|
||||
|
||||
**Location:** `~/.claude/skills/rag-search/`
|
||||
|
||||
```
|
||||
rag-search/
|
||||
├── SKILL.md # Instructions for Claude
|
||||
├── scripts/
|
||||
│ ├── search.py # Main search entry point
|
||||
│ ├── index_personal.py # Index state files
|
||||
│ ├── index_docs.py # Index external docs
|
||||
│ └── add_doc_source.py # Add new doc source
|
||||
└── references/
|
||||
└── sources.json # Configured doc sources
|
||||
```
|
||||
|
||||
## Skill Interface
|
||||
|
||||
### Invocation
|
||||
|
||||
```bash
|
||||
# Basic search (both indexes)
|
||||
~/.claude/skills/rag-search/scripts/search.py "how did I configure ArgoCD sync?"
|
||||
|
||||
# Search specific index
|
||||
~/.claude/skills/rag-search/scripts/search.py --index personal "past decisions about caching"
|
||||
~/.claude/skills/rag-search/scripts/search.py --index docs "k0s node maintenance"
|
||||
|
||||
# Control result count
|
||||
~/.claude/skills/rag-search/scripts/search.py --top-k 10 "prometheus alerting rules"
|
||||
```
|
||||
|
||||
### Output Format
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "how did I configure ArgoCD sync?",
|
||||
"results": [
|
||||
{
|
||||
"rank": 1,
|
||||
"score": 0.847,
|
||||
"source": "personal",
|
||||
"file": "memory/decisions.json",
|
||||
"chunk": "Decided to use ArgoCD auto-sync with self-heal disabled...",
|
||||
"metadata": {"date": "2025-01-15", "context": "k8s setup"}
|
||||
},
|
||||
{
|
||||
"rank": 2,
|
||||
"score": 0.823,
|
||||
"source": "docs",
|
||||
"file": "argocd/sync-options.md",
|
||||
"chunk": "Auto-sync can be configured with selfHeal and prune options...",
|
||||
"metadata": {"doc_version": "2.9", "url": "https://..."}
|
||||
}
|
||||
],
|
||||
"searched_collections": ["personal", "docs"],
|
||||
"total_chunks_searched": 1847
|
||||
}
|
||||
```
|
||||
|
||||
### SKILL.md Guidance
|
||||
|
||||
- Start with broad query, refine if results aren't relevant
|
||||
- Cross-reference personal decisions with docs when both appear
|
||||
- Cite sources in answers (file + date for personal, URL for docs)
|
||||
|
||||
## External Docs Management
|
||||
|
||||
### Source Registry
|
||||
|
||||
**Location:** `~/.claude/skills/rag-search/references/sources.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"sources": [
|
||||
{
|
||||
"id": "k0s",
|
||||
"name": "k0s Documentation",
|
||||
"type": "git",
|
||||
"url": "https://github.com/k0sproject/k0s.git",
|
||||
"path": "docs/",
|
||||
"glob": "**/*.md",
|
||||
"version": "v1.30.0",
|
||||
"last_indexed": "2025-01-20T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "argocd",
|
||||
"name": "ArgoCD Documentation",
|
||||
"type": "web",
|
||||
"base_url": "https://argo-cd.readthedocs.io/en/stable/",
|
||||
"pages": ["user-guide/sync-options/", "operator-manual/"],
|
||||
"last_indexed": "2025-01-18T14:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Adding Sources
|
||||
|
||||
```bash
|
||||
~/.claude/skills/rag-search/scripts/add_doc_source.py \
|
||||
--id "cilium" \
|
||||
--name "Cilium Docs" \
|
||||
--type git \
|
||||
--url "https://github.com/cilium/cilium.git" \
|
||||
--path "Documentation/" \
|
||||
--glob "**/*.md"
|
||||
|
||||
# Then index it
|
||||
~/.claude/skills/rag-search/scripts/index_docs.py --source cilium
|
||||
```
|
||||
|
||||
### Update Strategies
|
||||
|
||||
| Strategy | Command | When |
|
||||
|----------|---------|------|
|
||||
| Manual | `index_docs.py --source <id>` | After version upgrade |
|
||||
| All sources | `index_docs.py --all` | Periodic refresh |
|
||||
|
||||
## Periodic Refresh
|
||||
|
||||
Daily systemd timer on workstation.
|
||||
|
||||
### Service
|
||||
|
||||
**Location:** `~/.config/systemd/user/rag-index.service`
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Refresh RAG search indexes
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=%h/.claude/skills/rag-search/scripts/index_docs.py --all --quiet
|
||||
ExecStartPost=%h/.claude/skills/rag-search/scripts/index_personal.py --quiet
|
||||
Environment=PATH=%h/.claude/skills/rag-search/venv/bin:/usr/bin
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
### Timer
|
||||
|
||||
**Location:** `~/.config/systemd/user/rag-index.timer`
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Daily RAG index refresh
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
Persistent=true
|
||||
RandomizedDelaySec=3600
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
```
|
||||
|
||||
### Enable
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now rag-index.timer
|
||||
```
|
||||
|
||||
### Manual Trigger
|
||||
|
||||
```bash
|
||||
systemctl --user start rag-index.service
|
||||
journalctl --user -u rag-index.service # View logs
|
||||
```
|
||||
|
||||
## Resource Requirements
|
||||
|
||||
**Target:** Workstation or Pi5 8GB
|
||||
|
||||
| Component | RAM | Disk | Notes |
|
||||
|-----------|-----|------|-------|
|
||||
| Embedding model (all-MiniLM-L6-v2) | ~256MB | ~90MB | Loaded on-demand |
|
||||
| ChromaDB | ~100-500MB | Varies | Scales with index size |
|
||||
| Index: personal (~50 files) | — | ~5MB | Small, fast to query |
|
||||
| Index: docs (10-20 sources) | — | ~100-500MB | Depends on doc volume |
|
||||
| Indexing process (peak) | ~1GB | — | During embedding generation |
|
||||
|
||||
**Pi3 1GB:** Not suitable for this workload.
|
||||
|
||||
## Chunking Strategy
|
||||
|
||||
| Index | Strategy |
|
||||
|-------|----------|
|
||||
| Personal | Per JSON key or logical section (decisions, preferences, facts as separate chunks) |
|
||||
| Docs | ~500 tokens per chunk with overlap, preserve headers as metadata |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Recommended: Ralph Loop
|
||||
|
||||
This design is suitable for Ralph loop implementation:
|
||||
- Clear success criteria (tests, functional checks)
|
||||
- Iterative refinement expected (tuning chunking, embeddings)
|
||||
- Automatic verification possible
|
||||
|
||||
### Model Delegation
|
||||
|
||||
Use appropriate models for each phase:
|
||||
|
||||
| Phase | Task | Model |
|
||||
|-------|------|-------|
|
||||
| 1 | Set up ChromaDB + embedding model | Haiku |
|
||||
| 2 | Write `index_personal.py` | Sonnet |
|
||||
| 3 | Write `index_docs.py` | Sonnet |
|
||||
| 4 | Write `search.py` | Sonnet |
|
||||
| 5 | Write SKILL.md | Haiku |
|
||||
| 6 | Integration tests | Sonnet |
|
||||
| 7 | End-to-end validation | Sonnet |
|
||||
|
||||
### Ralph Invocation
|
||||
|
||||
```bash
|
||||
/ralph-loop "Implement rag-search skill per docs/plans/2025-01-21-agentic-rag-design.md.
|
||||
|
||||
Delegate to appropriate models:
|
||||
- Haiku: setup, docs, simple scripts
|
||||
- Sonnet: implementation, tests, debugging
|
||||
- Opus: only if stuck on complex reasoning
|
||||
|
||||
Success criteria:
|
||||
1. ChromaDB + embeddings working
|
||||
2. Personal index populated from ~/.claude/state
|
||||
3. At least one external doc source indexed
|
||||
4. search.py returns relevant results
|
||||
5. All tests pass
|
||||
|
||||
Output <promise>COMPLETE</promise> when done." --max-iterations 30 --completion-promise "COMPLETE"
|
||||
```
|
||||
|
||||
### When NOT to use Ralph
|
||||
|
||||
- Design decisions still needed (use brainstorming first)
|
||||
- Requires human judgment mid-implementation
|
||||
- One-shot simple tasks
|
||||
|
||||
## Workflow Integration
|
||||
|
||||
```
|
||||
/superpowers:brainstorm
|
||||
│
|
||||
▼
|
||||
Design doc created
|
||||
(docs/plans/YYYY-MM-DD-*-design.md)
|
||||
│
|
||||
▼
|
||||
"Ready to implement?"
|
||||
│
|
||||
┌────┴────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
Simple Complex/Iterative
|
||||
│ │
|
||||
▼ ▼
|
||||
Manual /ralph-loop
|
||||
or TDD with design doc
|
||||
as spec
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
| Aspect | Decision |
|
||||
|--------|----------|
|
||||
| **Architecture** | Extend existing Claude skill system with semantic search |
|
||||
| **Indexes** | Two: personal (state files) + docs (external) |
|
||||
| **Vector store** | ChromaDB (local, no deps) |
|
||||
| **Embeddings** | sentence-transformers (all-MiniLM-L6-v2) |
|
||||
| **Skill interface** | `rag-search` skill with `search.py` CLI |
|
||||
| **Doc management** | `sources.json` registry, git/web fetching |
|
||||
| **Refresh** | systemd user timer, daily |
|
||||
| **Storage** | `~/.claude/data/rag-search/` |
|
||||
| **Hardware** | Runs on workstation (Pi5 8GB capable if needed) |
|
||||
| **Implementation** | Ralph loop with Haiku/Sonnet subagent delegation |
|
||||
@@ -0,0 +1,442 @@
|
||||
# Claude Ops Dashboard Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Extend the existing Go web dashboard to monitor Claude Code agent activity, context usage, skills/commands usage signals, and cost-related token stats from your local `~/.claude/` directory.
|
||||
|
||||
**Architecture:** Keep the current lightweight Go HTTP server + static HTML/JS frontend. Add a new “Claude Ops” section with API endpoints that parse local Claude Code state files (primarily `~/.claude/stats-cache.json`, `~/.claude/history.jsonl`, `~/.claude/state/component-registry.json`, and directory listings under `~/.claude/agents`, `~/.claude/skills`, `~/.claude/commands`). Frontend gains new navigation tabs + tables/charts using vanilla JS.
|
||||
|
||||
**Tech Stack:** Go 1.21+, chi router, vanilla HTML/CSS/JS (no React), file-based data sources under `~/.claude/`.
|
||||
|
||||
---
|
||||
|
||||
## Scope (YAGNI)
|
||||
|
||||
In this first iteration, focus on read-only analytics:
|
||||
- Daily activity + token usage (from `~/.claude/stats-cache.json`)
|
||||
- Recent sessions list (from `~/.claude/history.jsonl` without parsing full message bodies)
|
||||
- Installed agents/skills/commands inventory (from `~/.claude/agents/`, `~/.claude/skills/`, `~/.claude/commands/`)
|
||||
- “Debug” view: show which data files are missing/unreadable and last-modified timestamps
|
||||
|
||||
Explicitly out of scope:
|
||||
- Real-time websocket streaming
|
||||
- Multi-user auth
|
||||
- Editing/triggering slash commands
|
||||
- Deep semantic parsing of every history event (we’ll add iteratively)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add Claude directory config to server
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/.claude/dashboard/cmd/server/main.go`
|
||||
- Modify: `~/.claude/dashboard/README.md`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Create a minimal unit test that ensures server config defaults to `~/.claude` when not specified.
|
||||
|
||||
- Create: `~/.claude/dashboard/cmd/server/config_test.go`
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultClaudeDir(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Fatalf("UserHomeDir: %v", err)
|
||||
}
|
||||
want := filepath.Join(home, ".claude")
|
||||
got := defaultClaudeDir()
|
||||
if got != want {
|
||||
t.Fatalf("defaultClaudeDir() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL with `undefined: defaultClaudeDir`
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
- Modify: `~/.claude/dashboard/cmd/server/main.go`
|
||||
|
||||
Add helper:
|
||||
```go
|
||||
func defaultClaudeDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "/home/will/.claude" // fallback; best-effort
|
||||
}
|
||||
return filepath.Join(home, ".claude")
|
||||
}
|
||||
```
|
||||
|
||||
Add CLI flag:
|
||||
- `--claude` (default `~/.claude`)
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/server/main.go cmd/server/config_test.go README.md
|
||||
git commit -m "feat: add default claude dir config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add Claude models for API responses
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/dashboard/internal/claude/models.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
- Create: `~/.claude/dashboard/internal/claude/models_test.go`
|
||||
|
||||
```go
|
||||
package claude
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestModelTypesCompile(t *testing.T) {
|
||||
_ = StatsCache{}
|
||||
_ = DailyActivity{}
|
||||
_ = ModelUsage{}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL with `undefined: StatsCache`
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
- Create: `~/.claude/dashboard/internal/claude/models.go`
|
||||
|
||||
Implement structs matching the subset we use:
|
||||
```go
|
||||
package claude
|
||||
|
||||
type DailyActivity struct {
|
||||
Date string `json:"date"`
|
||||
MessageCount int `json:"messageCount"`
|
||||
SessionCount int `json:"sessionCount"`
|
||||
ToolCallCount int `json:"toolCallCount"`
|
||||
}
|
||||
|
||||
type DailyModelTokens struct {
|
||||
Date string `json:"date"`
|
||||
TokensByModel map[string]int `json:"tokensByModel"`
|
||||
}
|
||||
|
||||
type ModelUsage struct {
|
||||
InputTokens int `json:"inputTokens"`
|
||||
OutputTokens int `json:"outputTokens"`
|
||||
CacheReadInputTokens int `json:"cacheReadInputTokens"`
|
||||
CacheCreationInputTokens int `json:"cacheCreationInputTokens"`
|
||||
WebSearchRequests int `json:"webSearchRequests"`
|
||||
CostUSD float64 `json:"costUSD"`
|
||||
ContextWindow int `json:"contextWindow"`
|
||||
}
|
||||
|
||||
type StatsCache struct {
|
||||
Version int `json:"version"`
|
||||
LastComputedDate string `json:"lastComputedDate"`
|
||||
DailyActivity []DailyActivity `json:"dailyActivity"`
|
||||
DailyModelTokens []DailyModelTokens `json:"dailyModelTokens"`
|
||||
ModelUsage map[string]ModelUsage `json:"modelUsage"`
|
||||
TotalSessions int `json:"totalSessions"`
|
||||
TotalMessages int `json:"totalMessages"`
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/claude/models.go internal/claude/models_test.go
|
||||
git commit -m "feat: add claude stats response models"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Implement Claude file loader
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/dashboard/internal/claude/loader.go`
|
||||
- Test: `~/.claude/dashboard/internal/claude/loader_test.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```go
|
||||
package claude
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadStatsCache(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "stats-cache.json")
|
||||
err := os.WriteFile(p, []byte(`{"version":1,"lastComputedDate":"2025-12-31","totalSessions":1,"totalMessages":2}`), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
loader := NewLoader(dir)
|
||||
stats, err := loader.LoadStatsCache()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadStatsCache: %v", err)
|
||||
}
|
||||
if stats.TotalSessions != 1 {
|
||||
t.Fatalf("TotalSessions=%d", stats.TotalSessions)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL with `undefined: NewLoader`
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Implement `Loader` with:
|
||||
- `NewLoader(claudeDir string)`
|
||||
- `LoadStatsCache() (*StatsCache, error)` reads `<claudeDir>/stats-cache.json`
|
||||
- `ListDir(name string) ([]DirEntry, error)` for `agents/`, `skills/`, `commands/`
|
||||
- `FileInfo(path string) (FileMeta, error)` for debug view
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/claude/loader.go internal/claude/loader_test.go
|
||||
git commit -m "feat: load claude stats-cache.json"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add new Claude Ops API routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/.claude/dashboard/cmd/server/main.go`
|
||||
- Create: `~/.claude/dashboard/internal/api/claude_handlers.go`
|
||||
- Modify: `~/.claude/dashboard/internal/api/handlers.go` (only if you want shared helpers)
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
- Create: `~/.claude/dashboard/internal/api/claude_handlers_test.go`
|
||||
|
||||
```go
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
type fakeLoader struct{}
|
||||
|
||||
func (f fakeLoader) LoadStatsCache() (*claude.StatsCache, error) {
|
||||
return &claude.StatsCache{TotalSessions: 3}, nil
|
||||
}
|
||||
|
||||
func TestGetClaudeStats(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/claude/stats", GetClaudeStats(fakeLoader{}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/claude/stats", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL with `undefined: GetClaudeStats`
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Create endpoints:
|
||||
- `GET /api/claude/health` → returns `{status:"ok", claudeDir:"..."}` and file presence checks
|
||||
- `GET /api/claude/stats` → returns parsed `StatsCache`
|
||||
- `GET /api/claude/inventory` → lists agents/skills/commands entries
|
||||
- `GET /api/claude/debug/files` → returns file metas for key files and last-modified
|
||||
|
||||
Wire in `cmd/server/main.go`:
|
||||
- Build loader from `--claude` flag
|
||||
- Register routes under `/api/claude`
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/server/main.go internal/api/claude_handlers.go internal/api/claude_handlers_test.go
|
||||
git commit -m "feat: add claude ops api endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add new UI navigation tabs
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/.claude/dashboard/cmd/server/web/index.html`
|
||||
- Modify: `~/.claude/dashboard/cmd/server/web/static/css/style.css`
|
||||
|
||||
**Step 1: Make a minimal UI change (no tests)**
|
||||
|
||||
Add nav buttons:
|
||||
- Overview
|
||||
- Usage
|
||||
- Inventory
|
||||
- Debug
|
||||
|
||||
Add new `<section>` elements mirroring existing “views” pattern.
|
||||
|
||||
**Step 2: Manual verification**
|
||||
|
||||
Run: `go run ./cmd/server --port 8080 --data /tmp/k8s --claude ~/.claude`
|
||||
Expected: New tabs switch views (even if empty).
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/server/web/index.html cmd/server/web/static/css/style.css
|
||||
git commit -m "feat: add claude ops dashboard views"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Implement frontend data fetching + rendering
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/.claude/dashboard/cmd/server/web/static/js/app.js`
|
||||
|
||||
**Step 1: Add API calls**
|
||||
|
||||
Add functions:
|
||||
- `loadClaudeStats()` → `GET /api/claude/stats`
|
||||
- `loadClaudeInventory()` → `GET /api/claude/inventory`
|
||||
- `loadClaudeDebugFiles()` → `GET /api/claude/debug/files`
|
||||
|
||||
Integrate into `loadAllData()`.
|
||||
|
||||
**Step 2: Add render functions**
|
||||
|
||||
- Overview: show totals + lastComputedDate
|
||||
- Usage: simple table for `dailyActivity` (date, messages, sessions, tool calls)
|
||||
- Inventory: 3 columns lists: agents, skills, commands
|
||||
- Debug: table of key files with status/missing + mtime
|
||||
|
||||
**Step 3: Manual verification**
|
||||
|
||||
Run: `go run ./cmd/server --port 8080 --data /tmp/k8s --claude ~/.claude`
|
||||
Expected: Data populates from your local `~/.claude/stats-cache.json`.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/server/web/static/js/app.js
|
||||
git commit -m "feat: render claude usage and inventory data"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Add “cost optimization” signals (derived)
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/.claude/dashboard/internal/api/claude_handlers.go`
|
||||
- Modify: `~/.claude/dashboard/internal/claude/models.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add a test that expects derived fields:
|
||||
- cache hit ratio estimate: `cacheReadInputTokens / (inputTokens + cacheReadInputTokens + cacheCreationInputTokens)` (best-effort)
|
||||
- top model by output tokens
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL because fields aren’t present
|
||||
|
||||
**Step 3: Implement derived summary endpoint**
|
||||
|
||||
- `GET /api/claude/summary` returns:
|
||||
- totals
|
||||
- per-model tokens
|
||||
- derived cost signals
|
||||
|
||||
**Step 4: Run tests**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/api/claude_handlers.go internal/claude/models.go internal/api/claude_handlers_test.go
|
||||
git commit -m "feat: add claude summary and cache efficiency signals"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: End-to-end check
|
||||
|
||||
**Files:**
|
||||
- None
|
||||
|
||||
**Step 1: Run tests**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 2: Run server locally**
|
||||
|
||||
Run: `go run ./cmd/server --port 8080 --data /tmp/k8s --claude ~/.claude`
|
||||
Expected: Browser shows Claude Ops tabs with live data.
|
||||
|
||||
---
|
||||
|
||||
## Notes / Follow-ups (later iterations)
|
||||
|
||||
- Parse `~/.claude/history.jsonl` into “sessions” and show recent slash commands usage (requires schema discovery)
|
||||
- Add “top tools called” chart (requires richer history parsing)
|
||||
- Add alert thresholds (e.g., token spikes day-over-day)
|
||||
@@ -0,0 +1,660 @@
|
||||
# Claude Real-Time Monitoring (SSE) Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add real-time-ish monitoring of Claude Code agent activity to the existing Go dashboard, with a backlog (last 200 history events) plus SSE updates, shown in a new “Live” UI feed with prettified rows and expandable raw JSON.
|
||||
|
||||
**Architecture:** Add an in-memory `EventHub` (pub/sub + ring buffer) that receives events from a `HistoryTailer` (tails `~/.claude/history.jsonl`) and a debounced file watcher (for `stats-cache.json` and `state/component-registry.json`). Expose a REST backlog endpoint (`/api/claude/live/backlog`) returning normalized `Event` objects (newest→oldest) and an SSE stream endpoint (`/api/claude/stream`) that pushes new events to the browser. Frontend uses `EventSource` plus batching (render every 1–2s).
|
||||
|
||||
**Tech Stack:** Go 1.21+, `chi`, vanilla HTML/CSS/JS, optional `fsnotify`.
|
||||
|
||||
---
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
- Transport: SSE first (WebSockets later).
|
||||
- Acceptable latency: 2–5 seconds.
|
||||
- UI: prettified table with expandable raw JSON.
|
||||
- Backlog: enabled, default `limit=200`.
|
||||
- Backlog ordering: **newest → oldest**.
|
||||
- Parsing: **generic-first**, best-effort extraction of a few fields; always preserve raw JSON.
|
||||
- Backlog response format: **normalized `events`** (not just raw lines).
|
||||
|
||||
---
|
||||
|
||||
## Data Contract
|
||||
|
||||
### Event JSON
|
||||
|
||||
All events share:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"ts": "2026-01-01T12:00:00Z",
|
||||
"type": "history.append",
|
||||
"data": {
|
||||
"summary": {
|
||||
"sessionId": "...",
|
||||
"project": "...",
|
||||
"display": "/model"
|
||||
},
|
||||
"rawLine": "{...}",
|
||||
"json": { "...": "..." },
|
||||
"parseError": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Event types:
|
||||
- `history.append`
|
||||
- `file.changed`
|
||||
- `server.notice`
|
||||
- `server.error`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `Event` types
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/dashboard/internal/claude/events.go`
|
||||
- Test: `~/.claude/dashboard/internal/claude/events_test.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```go
|
||||
package claude
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEventTypesCompile(t *testing.T) {
|
||||
_ = Event{}
|
||||
_ = EventTypeHistoryAppend
|
||||
_ = EventTypeFileChanged
|
||||
_ = EventTypeServerNotice
|
||||
_ = EventTypeServerError
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL with `undefined: Event`
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```go
|
||||
package claude
|
||||
|
||||
import "time"
|
||||
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventTypeHistoryAppend EventType = "history.append"
|
||||
EventTypeFileChanged EventType = "file.changed"
|
||||
EventTypeServerNotice EventType = "server.notice"
|
||||
EventTypeServerError EventType = "server.error"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID int64 `json:"id"`
|
||||
TS time.Time `json:"ts"`
|
||||
Type EventType `json:"type"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/claude/events.go internal/claude/events_test.go
|
||||
git commit -m "feat: add real-time event types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Implement `EventHub` (pub/sub + ring buffer)
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/dashboard/internal/claude/eventhub.go`
|
||||
- Test: `~/.claude/dashboard/internal/claude/eventhub_test.go`
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
```go
|
||||
package claude
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEventHub_PublishSubscribe(t *testing.T) {
|
||||
hub := NewEventHub(10)
|
||||
ch, cancel := hub.Subscribe()
|
||||
defer cancel()
|
||||
|
||||
hub.Publish(Event{TS: time.Unix(1, 0), Type: EventTypeServerNotice, Data: map[string]any{"msg": "hi"}})
|
||||
|
||||
select {
|
||||
case ev := <-ch:
|
||||
if ev.Type != EventTypeServerNotice {
|
||||
t.Fatalf("type=%s", ev.Type)
|
||||
}
|
||||
if ev.ID == 0 {
|
||||
t.Fatalf("expected id to be assigned")
|
||||
}
|
||||
default:
|
||||
t.Fatalf("expected event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventHub_ReplaySince(t *testing.T) {
|
||||
hub := NewEventHub(3)
|
||||
hub.Publish(Event{TS: time.Unix(1, 0), Type: EventTypeServerNotice}) // id 1
|
||||
hub.Publish(Event{TS: time.Unix(2, 0), Type: EventTypeServerNotice}) // id 2
|
||||
hub.Publish(Event{TS: time.Unix(3, 0), Type: EventTypeServerNotice}) // id 3
|
||||
|
||||
got := hub.ReplaySince(1)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len=%d", len(got))
|
||||
}
|
||||
if got[0].ID != 2 || got[1].ID != 3 {
|
||||
t.Fatalf("ids=%d,%d", got[0].ID, got[1].ID)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify RED**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL with `undefined: NewEventHub`
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Implement:
|
||||
- `type EventHub struct { ... }`
|
||||
- `NewEventHub(bufferSize int) *EventHub`
|
||||
- `Publish(ev Event) Event`:
|
||||
- assign `ID` if zero using an internal counter
|
||||
- set `TS = time.Now()` if zero
|
||||
- append to ring buffer
|
||||
- broadcast to subscriber channels (non-blocking send)
|
||||
- `Subscribe() (chan Event, func())` returns a buffered channel and a cancel func
|
||||
- `ReplaySince(lastID int64) []Event`
|
||||
|
||||
**Step 4: Run tests to verify GREEN**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/claude/eventhub.go internal/claude/eventhub_test.go
|
||||
git commit -m "feat: add event hub with replay buffer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Tail last N lines helper (newest → oldest)
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/dashboard/internal/claude/tail.go`
|
||||
- Test: `~/.claude/dashboard/internal/claude/tail_test.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```go
|
||||
package claude
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTailLastNLines_NewestFirst(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "history.jsonl")
|
||||
|
||||
var b strings.Builder
|
||||
for i := 1; i <= 5; i++ {
|
||||
b.WriteString("line")
|
||||
b.WriteString([]string{"1","2","3","4","5"}[i-1])
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(b.String()), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
lines, err := TailLastNLines(p, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("TailLastNLines: %v", err)
|
||||
}
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("len=%d", len(lines))
|
||||
}
|
||||
if lines[0] != "line5" || lines[1] != "line4" {
|
||||
t.Fatalf("got=%v", lines)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL with `undefined: TailLastNLines`
|
||||
|
||||
**Step 3: Minimal implementation**
|
||||
|
||||
Create `~/.claude/dashboard/internal/claude/tail.go`:
|
||||
|
||||
- `TailLastNLines(path string, n int) ([]string, error)`
|
||||
- First implementation can be simple (read whole file + split) with a TODO noting potential optimization.
|
||||
- Return text lines without trailing newline; newest→oldest ordering.
|
||||
|
||||
**Step 4: Run tests to verify it passes**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/claude/tail.go internal/claude/tail_test.go
|
||||
git commit -m "feat: add tail last N lines helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add backlog endpoint returning normalized events
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/dashboard/internal/api/claude_live_handlers.go`
|
||||
- Modify: `~/.claude/dashboard/internal/api/claude_handlers.go` (only to share helper if needed)
|
||||
- Modify: `~/.claude/dashboard/cmd/server/main.go`
|
||||
- Test: `~/.claude/dashboard/internal/api/claude_live_handlers_test.go`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
```go
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
type fakeClaudeDirLoader struct{ dir string }
|
||||
|
||||
func (f fakeClaudeDirLoader) ClaudeDir() string { return f.dir }
|
||||
func (f fakeClaudeDirLoader) LoadStatsCache() (*claude.StatsCache, error) { return &claude.StatsCache{}, nil }
|
||||
func (f fakeClaudeDirLoader) ListDir(name string) ([]claude.DirEntry, error) { return nil, nil }
|
||||
func (f fakeClaudeDirLoader) FileMeta(relPath string) (claude.FileMeta, error) { return claude.FileMeta{}, nil }
|
||||
func (f fakeClaudeDirLoader) PathExists(relPath string) bool { return true }
|
||||
|
||||
func TestClaudeLiveBacklog_DefaultLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "history.jsonl")
|
||||
if err := os.WriteFile(p, []byte("{\"display\":\"/model\"}\n"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
loader := fakeClaudeDirLoader{dir: dir}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/claude/live/backlog", GetClaudeLiveBacklog(loader))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/claude/live/backlog", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
// Assert response includes an "events" array with at least 1 event.
|
||||
if !jsonContainsKey(t, w.Body.Bytes(), "events") {
|
||||
t.Fatalf("expected events in response: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify RED**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL with `undefined: GetClaudeLiveBacklog`
|
||||
|
||||
**Step 3: Minimal implementation**
|
||||
|
||||
Create `~/.claude/dashboard/internal/api/claude_live_handlers.go`:
|
||||
|
||||
- `GetClaudeLiveBacklog(loader ClaudeLoader) http.HandlerFunc`
|
||||
- Query param: `limit` (default 200; clamp 1..1000)
|
||||
- Use `claude.TailLastNLines(filepath.Join(loader.ClaudeDir(), "history.jsonl"), limit)`
|
||||
- For each line, create a `claude.Event` with:
|
||||
- `Type: claude.EventTypeHistoryAppend`
|
||||
- `TS: time.Now()` (or parse timestamp if present in JSON)
|
||||
- `Data` contains: `rawLine`, optionally `json`, optionally `parseError`, and `summary` (best effort)
|
||||
- JSON parsing should be schema-agnostic: unmarshal into `map[string]any`.
|
||||
- Summary extraction should look for keys: `sessionId`, `project`, `display` (strings).
|
||||
|
||||
Return payload:
|
||||
|
||||
```json
|
||||
{ "limit": 200, "events": [ ... ] }
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify GREEN**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Wire route**
|
||||
|
||||
Modify `~/.claude/dashboard/cmd/server/main.go` to register:
|
||||
- `GET /api/claude/live/backlog`
|
||||
|
||||
**Step 6: Run tests again**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/server/main.go internal/api/claude_live_handlers.go internal/api/claude_live_handlers_test.go internal/claude/tail.go internal/claude/tail_test.go
|
||||
git commit -m "feat: add claude live backlog endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add SSE stream endpoint
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/dashboard/internal/api/claude_stream_handlers.go`
|
||||
- Modify: `~/.claude/dashboard/cmd/server/main.go`
|
||||
- Test: `~/.claude/dashboard/internal/api/claude_stream_handlers_test.go`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
```go
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/will/k8s-agent-dashboard/internal/claude"
|
||||
)
|
||||
|
||||
func TestClaudeStream_SendsEvent(t *testing.T) {
|
||||
hub := claude.NewEventHub(10)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/claude/stream", GetClaudeStream(hub))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/claude/stream", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Publish after handler starts.
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
hub.Publish(claude.Event{Type: claude.EventTypeServerNotice, Data: map[string]any{"msg": "hi"}})
|
||||
}()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
|
||||
t.Fatalf("content-type=%q", ct)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "event:") || !strings.Contains(w.Body.String(), "data:") {
|
||||
t.Fatalf("body=%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify RED**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL with `undefined: GetClaudeStream`
|
||||
|
||||
**Step 3: Implement minimal SSE handler**
|
||||
|
||||
Create `~/.claude/dashboard/internal/api/claude_stream_handlers.go`:
|
||||
|
||||
- `GetClaudeStream(hub *claude.EventHub) http.HandlerFunc`
|
||||
- Set headers:
|
||||
- `Content-Type: text/event-stream`
|
||||
- `Cache-Control: no-cache`
|
||||
- Subscribe to hub; write events in SSE format:
|
||||
|
||||
```
|
||||
event: <type>
|
||||
id: <id>
|
||||
data: <json>
|
||||
|
||||
|
||||
```
|
||||
|
||||
- Flush after each event.
|
||||
- Keep it minimal; add keepalive pings later.
|
||||
|
||||
**Step 4: Run tests to verify GREEN**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Wire route**
|
||||
|
||||
Modify `~/.claude/dashboard/cmd/server/main.go` to register:
|
||||
- `GET /api/claude/stream`
|
||||
|
||||
**Step 6: Run tests again**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/server/main.go internal/api/claude_stream_handlers.go internal/api/claude_stream_handlers_test.go internal/claude/eventhub.go internal/claude/eventhub_test.go
|
||||
git commit -m "feat: add claude sse stream endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Implement HistoryTailer to publish hub events
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/dashboard/internal/claude/history_tailer.go`
|
||||
- Test: `~/.claude/dashboard/internal/claude/history_tailer_test.go`
|
||||
- Modify: `~/.claude/dashboard/cmd/server/main.go`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
```go
|
||||
package claude
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHistoryTailer_EmitsOnAppend(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "history.jsonl")
|
||||
if err := os.WriteFile(p, []byte(""), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
hub := NewEventHub(10)
|
||||
ch, cancel := hub.Subscribe()
|
||||
defer cancel()
|
||||
|
||||
stop := make(chan struct{})
|
||||
go TailHistoryFile(stop, hub, p)
|
||||
|
||||
// Append a line
|
||||
if err := os.WriteFile(p, []byte("{\"display\":\"/status\"}\n"), 0o600); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case ev := <-ch:
|
||||
if ev.Type != EventTypeHistoryAppend {
|
||||
t.Fatalf("type=%s", ev.Type)
|
||||
}
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
t.Fatalf("timed out waiting for event")
|
||||
}
|
||||
|
||||
close(stop)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify RED**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: FAIL with `undefined: TailHistoryFile`
|
||||
|
||||
**Step 3: Minimal implementation**
|
||||
|
||||
Create `~/.claude/dashboard/internal/claude/history_tailer.go` implementing:
|
||||
|
||||
- `TailHistoryFile(stop <-chan struct{}, hub *EventHub, path string)`
|
||||
- Simple polling loop (since target latency is 2–5s):
|
||||
- Every 500ms–1s, stat file size
|
||||
- If size grew, read new bytes from offset, split on `\n`, publish `history.append` events
|
||||
- If size shrank, reset offset to 0 and publish `server.notice`
|
||||
|
||||
Also implement an internal helper to parse a history line into event `Data` with `summary` extraction (same logic as backlog).
|
||||
|
||||
**Step 4: Run tests to verify GREEN**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Wire tailer in server**
|
||||
|
||||
Modify `~/.claude/dashboard/cmd/server/main.go`:
|
||||
- Create hub at startup: `hub := claude.NewEventHub(1000)`
|
||||
- Start goroutine tailing `filepath.Join(*claudeDir, "history.jsonl")`
|
||||
|
||||
**Step 6: Run tests again**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/server/main.go internal/claude/history_tailer.go internal/claude/history_tailer_test.go
|
||||
git commit -m "feat: stream history.jsonl appends via event hub"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Frontend Live view (EventSource + batching)
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/.claude/dashboard/cmd/server/web/index.html`
|
||||
- Modify: `~/.claude/dashboard/cmd/server/web/static/js/app.js`
|
||||
- Modify: `~/.claude/dashboard/cmd/server/web/static/css/style.css`
|
||||
|
||||
**Step 1: Add Live tab + markup**
|
||||
|
||||
- Add nav button: `data-view="live"`
|
||||
- Add section:
|
||||
- `id="live-view"`
|
||||
- table `id="claude-live-table"` and a connection indicator `id="claude-live-conn"`
|
||||
|
||||
**Step 2: Add JS backlog fetch + EventSource**
|
||||
|
||||
Modify `~/.claude/dashboard/cmd/server/web/static/js/app.js`:
|
||||
- On DOMContentLoaded, create `EventSource('/api/claude/stream')`
|
||||
- Maintain:
|
||||
- `let pendingLiveEvents = []`
|
||||
- `let liveEvents = []` (cap at 500)
|
||||
- Every 1000ms:
|
||||
- move pending → live
|
||||
- render table rows
|
||||
- Fetch backlog once:
|
||||
- `GET /api/claude/live/backlog?limit=200`
|
||||
- prepend/append into `liveEvents` (newest→oldest returned; UI should render newest first at top)
|
||||
|
||||
**Step 3: CSS**
|
||||
|
||||
- Add a small connection badge style (green/yellow/red)
|
||||
- Ensure table remains readable
|
||||
|
||||
**Step 4: Manual verification**
|
||||
|
||||
Run:
|
||||
- `go test ./...`
|
||||
- `go run ./cmd/server --port 8080 --data /tmp/k8s --claude ~/.claude`
|
||||
|
||||
Expected:
|
||||
- Live tab loads backlog rows
|
||||
- New history events appear on subsequent CLI activity
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/server/web/index.html cmd/server/web/static/js/app.js cmd/server/web/static/css/style.css
|
||||
git commit -m "feat: add live feed UI with SSE batching"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: End-to-end verification
|
||||
|
||||
**Files:**
|
||||
- None (unless a bug requires fixes)
|
||||
|
||||
**Step 1: Run full test suite**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS (0 failures)
|
||||
|
||||
**Step 2: Manual smoke check**
|
||||
|
||||
Run:
|
||||
- `go run ./cmd/server --port 8080 --data /tmp/k8s --claude ~/.claude`
|
||||
|
||||
Check:
|
||||
- `curl -N http://localhost:8080/api/claude/stream` prints SSE lines
|
||||
- `curl http://localhost:8080/api/claude/live/backlog?limit=5` returns `events` array
|
||||
- Browser Live tab updates
|
||||
|
||||
---
|
||||
|
||||
## Execution handoff
|
||||
|
||||
Plan complete and saved to `~/.claude/docs/plans/2026-01-01-claude-realtime-monitoring-implementation.md`.
|
||||
|
||||
Two execution options:
|
||||
|
||||
1. Subagent-Driven (this session) — I dispatch fresh subagent per task, review between tasks, fast iteration
|
||||
2. Parallel Session (separate) — Open new session with `superpowers:executing-plans`, batch execution with checkpoints
|
||||
|
||||
Which approach?
|
||||
@@ -0,0 +1,75 @@
|
||||
# Design: Google Tasks Skill
|
||||
|
||||
## Summary
|
||||
|
||||
Minimal `/tasks` skill to list Google Tasks. Thin wrapper around existing morning-report collector.
|
||||
|
||||
## Scope
|
||||
|
||||
- **In scope:** List pending tasks
|
||||
- **Out of scope (for now):** Complete, add, delete, filter by list
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
skills/gtasks/
|
||||
├── SKILL.md
|
||||
└── scripts/
|
||||
└── list.py
|
||||
```
|
||||
|
||||
## SKILL.md
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: gtasks
|
||||
description: Google Tasks read access — list pending tasks. Use when asked about tasks, todos, or what needs to be done.
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
---
|
||||
```
|
||||
|
||||
Quick command: `$GMAIL_PY ~/.claude/skills/gtasks/scripts/list.py`
|
||||
|
||||
Request routing:
|
||||
| User Request | Action |
|
||||
|--------------|--------|
|
||||
| "What are my tasks?" | `list.py` |
|
||||
| "Show my todos" | `list.py` |
|
||||
| "/tasks" | `list.py` |
|
||||
|
||||
Policy: Read-only, summarize results.
|
||||
|
||||
## list.py Script
|
||||
|
||||
Thin wrapper that imports from `morning-report/scripts/collectors/gtasks.py`.
|
||||
|
||||
- Default shows up to 10 tasks
|
||||
- Optional arg for more: `list.py 20`
|
||||
- Reuses existing auth and fetch logic
|
||||
|
||||
## Registry Updates
|
||||
|
||||
**skills:**
|
||||
```json
|
||||
"gtasks": {
|
||||
"description": "Google Tasks read access - list pending tasks",
|
||||
"triggers": ["tasks", "todo", "to do", "to-do", "pending"]
|
||||
}
|
||||
```
|
||||
|
||||
**commands:**
|
||||
```json
|
||||
"/tasks": {
|
||||
"description": "List Google Tasks",
|
||||
"aliases": ["/todo", "/todos"],
|
||||
"invokes": "skill:gtasks"
|
||||
}
|
||||
```
|
||||
|
||||
## Future Expansion
|
||||
|
||||
- Complete task by ID
|
||||
- Filter by task list
|
||||
- Show due dates
|
||||
@@ -0,0 +1,296 @@
|
||||
# Workstation Monitoring Design
|
||||
|
||||
## Overview
|
||||
|
||||
Deploy comprehensive monitoring for the Arch Linux workstation (willlaptop) by integrating with the existing k8s monitoring stack. This will enable proactive alerting for resource exhaustion, long-term capacity planning, and performance debugging.
|
||||
|
||||
**Reference:** Future consideration `fc-001` (workstation monitoring)
|
||||
|
||||
## Current Infrastructure
|
||||
|
||||
- **Workstation:** Arch Linux on MacBookPro9,2 (hostname: willlaptop)
|
||||
- **K8s Cluster:** kube-prometheus-stack deployed with Prometheus, Alertmanager, Grafana
|
||||
- **Network:** Direct network connectivity between workstation and cluster nodes
|
||||
- **Existing Monitoring:** 3 node_exporters on cluster nodes, cluster-level alerts configured
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
```
|
||||
┌─────────────────┐ HTTP/9100 ┌──────────────────────┐
|
||||
│ Workstation │ ──────────────────> │ K8s Prometheus │
|
||||
│ (willlaptop) │ scrape every 15s │ (monitoring ns) │
|
||||
│ │ │ │
|
||||
│ node_exporter │ │ workstation rules │
|
||||
│ systemd service│ │ + scrape config │
|
||||
└─────────────────┘ └──────────────────────┘
|
||||
│
|
||||
v
|
||||
┌──────────────────────┐
|
||||
│ Alertmanager │
|
||||
│ (existing setup) │
|
||||
│ unified routing │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **node_exporter** exposes metrics on `http://willlaptop:9100/metrics`
|
||||
2. **Prometheus** scrapes metrics every 15s via static target configuration
|
||||
3. **PrometheusRule** evaluates workstation-specific alert rules
|
||||
4. **Alertmanager** routes alerts to existing notification channels
|
||||
|
||||
## Workstation Deployment
|
||||
|
||||
### node_exporter Service
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
pacman -S prometheus-node-exporter
|
||||
```
|
||||
|
||||
**Systemd Configuration:**
|
||||
- Service: `node_exporter.service`
|
||||
- User: `node_exporter` (created by package)
|
||||
- Listen address: `0.0.0.0:9100`
|
||||
- Restart policy: `always` with 10s delay
|
||||
- Logging: systemd journal (`journalctl -u node_exporter`)
|
||||
|
||||
**ExecStart flags:**
|
||||
```bash
|
||||
/usr/bin/node_exporter --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($|/)
|
||||
```
|
||||
|
||||
Excludes system mounts to reduce noise.
|
||||
|
||||
**Firewall Configuration:**
|
||||
- Allow TCP 9100 from cluster nodes
|
||||
- Use ufw or iptables to restrict access
|
||||
|
||||
**Metrics Collected:**
|
||||
All default collectors except resource-intensive ones:
|
||||
- CPU, memory, filesystem, network
|
||||
- System stats (uptime, load average, systemd)
|
||||
- Thermal (if available on hardware)
|
||||
- Disk I/O
|
||||
|
||||
## Prometheus Configuration
|
||||
|
||||
### Static Scrape Target
|
||||
|
||||
**Job configuration:**
|
||||
- Job name: `workstation/willlaptop`
|
||||
- Target: `willlaptop:9100` (DNS resolution) or workstation IP
|
||||
- Scrape interval: `15s` (matches cluster node_exporter)
|
||||
- Scrape timeout: `10s`
|
||||
- Metrics path: `/metrics`
|
||||
- Honor labels: `true`
|
||||
|
||||
**Relabeling rules:**
|
||||
- Add `env: "workstation"` label for identification
|
||||
- Preserve `instance: "willlaptop"` from target
|
||||
|
||||
**Integration:**
|
||||
Add to existing Prometheus CRD configuration in kube-prometheus-stack. This can be done via:
|
||||
- PrometheusRule with additional scrape config, or
|
||||
- Direct modification of Prometheus configuration
|
||||
|
||||
## Alert Rules
|
||||
|
||||
### PrometheusRule Resource
|
||||
|
||||
**Namespace:** `monitoring`
|
||||
**Kind:** `PrometheusRule`
|
||||
**Labels:** Standard discovery labels for Prometheus operator
|
||||
|
||||
### Alert Categories
|
||||
|
||||
#### Critical Alerts (Paging)
|
||||
|
||||
1. **WorkstationDiskSpaceCritical**
|
||||
- Condition: `<5%` free on any mounted filesystem
|
||||
- Duration: 5m
|
||||
- Severity: critical
|
||||
|
||||
2. **WorkstationMemoryCritical**
|
||||
- Condition: `>95%` memory usage
|
||||
- Duration: 5m
|
||||
- Severity: critical
|
||||
|
||||
3. **WorkstationCPUCritical**
|
||||
- Condition: `>95%` CPU usage
|
||||
- Duration: 10m
|
||||
- Severity: critical
|
||||
|
||||
4. **WorkstationSystemdFailed**
|
||||
- Condition: Failed systemd units detected
|
||||
- Duration: 5m
|
||||
- Severity: critical
|
||||
|
||||
#### Warning Alerts (Email/Slack)
|
||||
|
||||
1. **WorkstationDiskSpaceWarning**
|
||||
- Condition: `<10%` free on any mounted filesystem
|
||||
- Duration: 10m
|
||||
- Severity: warning
|
||||
|
||||
2. **WorkstationMemoryWarning**
|
||||
- Condition: `>85%` memory usage
|
||||
- Duration: 10m
|
||||
- Severity: warning
|
||||
|
||||
3. **WorkstationCPUWarning**
|
||||
- Condition: `>80%` CPU usage
|
||||
- Duration: 15m
|
||||
- Severity: warning
|
||||
|
||||
4. **WorkstationLoadHigh**
|
||||
- Condition: 5m load average > # CPU cores
|
||||
- Duration: 10m
|
||||
- Severity: warning
|
||||
|
||||
5. **WorkstationDiskInodeWarning**
|
||||
- Condition: `<10%` inodes free
|
||||
- Duration: 10m
|
||||
- Severity: warning
|
||||
|
||||
6. **WorkstationNetworkErrors**
|
||||
- Condition: High packet loss or error rate
|
||||
- Duration: 10m
|
||||
- Severity: warning
|
||||
|
||||
#### Info Alerts (Log Only)
|
||||
|
||||
1. **WorkstationDiskSpaceInfo**
|
||||
- Condition: `<20%` free on any mounted filesystem
|
||||
- Duration: 15m
|
||||
- Severity: info
|
||||
|
||||
2. **WorkstationUptime**
|
||||
- Condition: System uptime metric (recording rule)
|
||||
- Severity: info
|
||||
|
||||
### Alert Annotations
|
||||
|
||||
Each alert includes:
|
||||
- `summary`: Brief description
|
||||
- `description`: Detailed explanation with metric values
|
||||
- `runbook_url`: Link to troubleshooting documentation (if available)
|
||||
|
||||
## Versioning
|
||||
|
||||
### Repository Structure
|
||||
|
||||
```
|
||||
~/.claude/repos/homelab/charts/willlaptop-monitoring/
|
||||
├── prometheus-rules.yaml # PrometheusRule for workstation alerts
|
||||
├── values.yaml # Configuration values
|
||||
└── README.md # Documentation
|
||||
```
|
||||
|
||||
### Values.yaml Configuration
|
||||
|
||||
Configurable parameters:
|
||||
```yaml
|
||||
workstation:
|
||||
hostname: willlaptop
|
||||
ip: <workstation_ip> # optional, fallback to DNS
|
||||
|
||||
scrape:
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
|
||||
alerts:
|
||||
disk:
|
||||
critical_percent: 5
|
||||
warning_percent: 10
|
||||
info_percent: 20
|
||||
memory:
|
||||
critical_percent: 95
|
||||
warning_percent: 85
|
||||
cpu:
|
||||
critical_percent: 95
|
||||
critical_duration: 10m
|
||||
warning_percent: 80
|
||||
warning_duration: 15m
|
||||
```
|
||||
|
||||
### Integration with ArgoCD
|
||||
|
||||
Follows existing GitOps pattern (charts/kube-prometheus-stack). Can be added to ArgoCD for automated deployments if desired.
|
||||
|
||||
## Testing and Verification
|
||||
|
||||
### Phase 1 - Workstation Deployment
|
||||
|
||||
1. Verify node_exporter installation:
|
||||
```bash
|
||||
pacman -Q prometheus-node-exporter
|
||||
```
|
||||
|
||||
2. Check systemd service status:
|
||||
```bash
|
||||
systemctl status node_exporter
|
||||
```
|
||||
|
||||
3. Verify metrics endpoint locally:
|
||||
```bash
|
||||
curl http://localhost:9100/metrics | head -20
|
||||
```
|
||||
|
||||
4. Test accessibility from cluster:
|
||||
```bash
|
||||
kubectl run -it --rm debug --image=curlimages/curl -- curl willlaptop:9100/metrics
|
||||
```
|
||||
|
||||
### Phase 2 - Prometheus Integration
|
||||
|
||||
1. Verify Prometheus target:
|
||||
- Access Prometheus UI → Targets → workstation/willlaptop
|
||||
- Confirm target is UP
|
||||
|
||||
2. Verify metric ingestion:
|
||||
```bash
|
||||
# Query in Prometheus UI
|
||||
node_up{instance="willlaptop"}
|
||||
```
|
||||
|
||||
3. Verify label injection:
|
||||
- Confirm `env="workstation"` label appears on metrics
|
||||
|
||||
### Phase 3 - Alert Verification
|
||||
|
||||
1. Review PrometheusRule:
|
||||
```bash
|
||||
kubectl get prometheusrule workstation-alerts -n monitoring -o yaml
|
||||
```
|
||||
|
||||
2. Verify rule evaluation:
|
||||
- Access Prometheus UI → Rules
|
||||
- Confirm workstation rules are active
|
||||
|
||||
3. Test critical alert:
|
||||
- Temporarily trigger a low disk alert (or simulate)
|
||||
- Verify alert fires in Prometheus UI
|
||||
|
||||
4. Verify Alertmanager integration:
|
||||
- Check Alertmanager UI → Alerts
|
||||
- Confirm workstation alerts are received
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] node_exporter running on workstation
|
||||
- [ ] Metrics accessible from cluster nodes
|
||||
- [ ] Prometheus scraping workstation metrics
|
||||
- [ ] Alert rules evaluated and firing correctly
|
||||
- [ ] Alerts routing through Alertmanager
|
||||
- [ ] Configuration versioned in homelab repository
|
||||
- [ ] Documentation complete
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Grafana dashboards for workstation metrics
|
||||
- Alert tuning based on observed patterns
|
||||
- Additional collectors (e.g., temperature sensors if available)
|
||||
- Integration with morning-report skill for health status
|
||||
@@ -1,5 +1,16 @@
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.claude/hooks/scripts/prompt-context.py",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
@@ -19,6 +30,29 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.claude/hooks/scripts/session-end.sh",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash|Write|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.claude/hooks/scripts/guardrail.py",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Guardrail Confirm Helper
|
||||
|
||||
Adds an operation to the session allowlist so it can proceed on retry.
|
||||
|
||||
Usage:
|
||||
python3 guardrail-confirm.py "<tool>" "<operation>"
|
||||
|
||||
Example:
|
||||
python3 guardrail-confirm.py "Bash" "rm -rf ~/Downloads/old-project"
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
HOME = Path.home()
|
||||
STATE_DIR = HOME / ".claude" / "state"
|
||||
SESSION_FILE = STATE_DIR / "guardrail-session.json"
|
||||
|
||||
|
||||
def load_session():
|
||||
"""Load current session allowlist."""
|
||||
if not SESSION_FILE.exists():
|
||||
return {"confirmed": []}
|
||||
try:
|
||||
with open(SESSION_FILE) as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return {"confirmed": []}
|
||||
|
||||
|
||||
def save_session(data: dict):
|
||||
"""Save session allowlist."""
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(SESSION_FILE, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print("Usage: guardrail-confirm.py <tool> <operation>")
|
||||
print("Example: guardrail-confirm.py 'Bash' 'rm ~/Downloads/old'")
|
||||
sys.exit(1)
|
||||
|
||||
tool = sys.argv[1]
|
||||
operation = sys.argv[2]
|
||||
|
||||
# Load current session
|
||||
session = load_session()
|
||||
|
||||
# Check if already confirmed
|
||||
for item in session.get("confirmed", []):
|
||||
if item.get("tool") == tool and item.get("operation") == operation:
|
||||
print(f"Already confirmed: {tool} - {operation[:50]}...")
|
||||
sys.exit(0)
|
||||
|
||||
# Add to allowlist
|
||||
session["confirmed"].append({
|
||||
"tool": tool,
|
||||
"operation": operation,
|
||||
"ts": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
})
|
||||
|
||||
save_session(session)
|
||||
print(f"Confirmed: {tool} - {operation[:50]}...")
|
||||
print("You may now retry the operation.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+283
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Guardrail PreToolUse Hook
|
||||
|
||||
Intercepts Bash, Write, and Edit tool calls to prevent dangerous operations.
|
||||
Returns JSON decision: {"decision": "allow"} or {"decision": "block", "reason": "..."}
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Paths
|
||||
HOME = Path.home()
|
||||
STATE_DIR = HOME / ".claude" / "state"
|
||||
LOGS_DIR = HOME / ".claude" / "logs"
|
||||
CONFIG_FILE = STATE_DIR / "guardrails.json"
|
||||
SESSION_FILE = STATE_DIR / "guardrail-session.json"
|
||||
LOG_FILE = LOGS_DIR / "guardrail.jsonl"
|
||||
|
||||
|
||||
def load_config():
|
||||
"""Load guardrails configuration."""
|
||||
if not CONFIG_FILE.exists():
|
||||
return None
|
||||
with open(CONFIG_FILE) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_session_allowlist():
|
||||
"""Load session allowlist of confirmed operations."""
|
||||
if not SESSION_FILE.exists():
|
||||
return {"confirmed": []}
|
||||
try:
|
||||
with open(SESSION_FILE) as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return {"confirmed": []}
|
||||
|
||||
|
||||
def is_in_allowlist(tool: str, operation: str) -> bool:
|
||||
"""Check if operation was previously confirmed."""
|
||||
allowlist = load_session_allowlist()
|
||||
for item in allowlist.get("confirmed", []):
|
||||
if item.get("tool") == tool and item.get("operation") == operation:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def expand_path(path: str) -> Path:
|
||||
"""Expand ~ and resolve path."""
|
||||
return Path(os.path.expanduser(path)).resolve()
|
||||
|
||||
|
||||
def is_under_path(target: Path, parent: str) -> bool:
|
||||
"""Check if target is under parent path."""
|
||||
try:
|
||||
parent_path = expand_path(parent)
|
||||
# Handle glob patterns like ~/projects/*
|
||||
if "*" in parent:
|
||||
# For ~/projects/*, check if under ~/projects
|
||||
parent_path = expand_path(parent.replace("/*", "").replace("*", ""))
|
||||
return parent_path in target.parents or target == parent_path
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def is_in_git_repo(path: Path) -> bool:
|
||||
"""Check if path is inside a git repository."""
|
||||
current = path if path.is_dir() else path.parent
|
||||
while current != current.parent:
|
||||
if (current / ".git").exists():
|
||||
return True
|
||||
current = current.parent
|
||||
return False
|
||||
|
||||
|
||||
def classify_path(target_path: str, config: dict) -> str:
|
||||
"""
|
||||
Classify a path as 'blocked', 'safe', or 'outside'.
|
||||
|
||||
Evaluation order:
|
||||
1. Blocked paths -> 'blocked'
|
||||
2. Safe paths -> 'safe'
|
||||
3. Git repo -> 'safe'
|
||||
4. Otherwise -> 'outside'
|
||||
"""
|
||||
try:
|
||||
target = expand_path(target_path)
|
||||
except (ValueError, OSError):
|
||||
return "outside"
|
||||
|
||||
# Check blocked paths first
|
||||
for blocked in config.get("blocked_paths", []):
|
||||
if is_under_path(target, blocked):
|
||||
return "blocked"
|
||||
|
||||
# Check safe paths
|
||||
for safe in config.get("safe_paths", []):
|
||||
if is_under_path(target, safe):
|
||||
return "safe"
|
||||
|
||||
# Check if in git repo
|
||||
if is_in_git_repo(target):
|
||||
return "safe"
|
||||
|
||||
return "outside"
|
||||
|
||||
|
||||
def extract_paths_from_command(command: str) -> list[str]:
|
||||
"""Extract potential file paths from a bash command."""
|
||||
paths = []
|
||||
# Simple heuristic: look for things that look like paths
|
||||
# This catches ~/..., /..., and relative paths
|
||||
tokens = command.split()
|
||||
for token in tokens:
|
||||
# Skip flags
|
||||
if token.startswith("-"):
|
||||
continue
|
||||
# Skip common commands
|
||||
if token in ("rm", "mv", "cp", "chmod", "chown", "mkdir", "rmdir", "touch"):
|
||||
continue
|
||||
# Check if it looks like a path
|
||||
if "/" in token or token.startswith("~"):
|
||||
paths.append(token)
|
||||
return paths
|
||||
|
||||
|
||||
def check_bash_rules(command: str, config: dict) -> tuple[str, str | None, str]:
|
||||
"""
|
||||
Check bash command against rules.
|
||||
Returns: (action, rule_name, path_context)
|
||||
action: 'allow', 'block', or 'confirm'
|
||||
"""
|
||||
rules = config.get("rules", {}).get("bash", [])
|
||||
|
||||
for rule in rules:
|
||||
pattern = rule.get("pattern", "")
|
||||
action = rule.get("action", "allow")
|
||||
name = rule.get("name", "unnamed")
|
||||
outside_safe_only = rule.get("outside_safe_paths", False)
|
||||
|
||||
# Check if pattern matches
|
||||
if re.search(pattern, command):
|
||||
if outside_safe_only:
|
||||
# Only apply rule if operating outside safe paths
|
||||
paths = extract_paths_from_command(command)
|
||||
for path in paths:
|
||||
path_class = classify_path(path, config)
|
||||
if path_class in ("blocked", "outside"):
|
||||
return (action, name, path_class)
|
||||
# All paths are safe, allow
|
||||
continue
|
||||
else:
|
||||
# Rule applies regardless of path
|
||||
return (action, name, "n/a")
|
||||
|
||||
return ("allow", None, "safe")
|
||||
|
||||
|
||||
def check_file_rules(file_path: str, tool: str, config: dict) -> tuple[str, str | None, str]:
|
||||
"""
|
||||
Check Write/Edit file path against rules.
|
||||
Returns: (action, rule_name, path_context)
|
||||
"""
|
||||
rules = config.get("rules", {}).get(tool.lower(), [])
|
||||
path_class = classify_path(file_path, config)
|
||||
|
||||
for rule in rules:
|
||||
path_match = rule.get("path_match", "")
|
||||
action = rule.get("action", "allow")
|
||||
name = rule.get("name", "unnamed")
|
||||
|
||||
if path_match == "blocked_paths" and path_class == "blocked":
|
||||
return (action, name, path_class)
|
||||
elif path_match == "outside_safe_paths" and path_class == "outside":
|
||||
return (action, name, path_class)
|
||||
|
||||
return ("allow", None, path_class)
|
||||
|
||||
|
||||
def log_action(tool: str, operation: str, action: str, rule: str | None, path_context: str):
|
||||
"""Log guardrail action to audit log."""
|
||||
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
entry = {
|
||||
"ts": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
"tool": tool,
|
||||
"operation": operation[:200], # Truncate long operations
|
||||
"action": action,
|
||||
"rule": rule or "none",
|
||||
"path_context": path_context
|
||||
}
|
||||
|
||||
with open(LOG_FILE, "a") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
|
||||
|
||||
def allow():
|
||||
"""Return allow decision."""
|
||||
print(json.dumps({"decision": "allow"}))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def block(reason: str):
|
||||
"""Return block decision with reason."""
|
||||
print(json.dumps({"decision": "block", "reason": reason}))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def main():
|
||||
# Read input from stdin
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
except json.JSONDecodeError:
|
||||
allow() # If we can't parse input, allow (fail open)
|
||||
|
||||
tool_name = input_data.get("tool_name", "")
|
||||
tool_input = input_data.get("tool_input", {})
|
||||
|
||||
# Only check Bash, Write, Edit
|
||||
if tool_name not in ("Bash", "Write", "Edit"):
|
||||
allow()
|
||||
|
||||
# Load config
|
||||
config = load_config()
|
||||
if not config:
|
||||
allow() # No config, allow everything
|
||||
|
||||
# Determine operation string for allowlist check
|
||||
if tool_name == "Bash":
|
||||
operation = tool_input.get("command", "")
|
||||
else:
|
||||
operation = tool_input.get("file_path", "")
|
||||
|
||||
# Check session allowlist first
|
||||
if is_in_allowlist(tool_name, operation):
|
||||
log_action(tool_name, operation, "confirmed_allow", "session_allowlist", "n/a")
|
||||
allow()
|
||||
|
||||
# Check rules based on tool type
|
||||
if tool_name == "Bash":
|
||||
action, rule_name, path_context = check_bash_rules(operation, config)
|
||||
else:
|
||||
action, rule_name, path_context = check_file_rules(operation, tool_name, config)
|
||||
|
||||
# Take action
|
||||
if action == "allow":
|
||||
allow()
|
||||
|
||||
# Log blocked/confirm actions
|
||||
log_action(tool_name, operation, action if action == "block" else "confirm_required", rule_name, path_context)
|
||||
|
||||
# Build block message
|
||||
if action == "block":
|
||||
reason = f"""GUARDRAIL BLOCKED: Operation not allowed.
|
||||
Tool: {tool_name}
|
||||
Operation: {operation}
|
||||
Rule: {rule_name}
|
||||
Path context: {path_context}
|
||||
|
||||
This operation is blocked by guardrail policy and cannot proceed."""
|
||||
else: # confirm
|
||||
confirm_cmd = f'python3 ~/.claude/hooks/scripts/guardrail-confirm.py "{tool_name}" "{operation}"'
|
||||
reason = f"""GUARDRAIL: User confirmation required.
|
||||
Tool: {tool_name}
|
||||
Operation: {operation}
|
||||
Rule: {rule_name}
|
||||
Path context: {path_context}
|
||||
|
||||
To proceed after user confirms, run:
|
||||
{confirm_cmd}
|
||||
|
||||
Then retry the original operation."""
|
||||
|
||||
block(reason)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+173
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
UserPromptSubmit hook - inject contextual information based on prompt.
|
||||
|
||||
Injects:
|
||||
- Time-aware context
|
||||
- Current git branch (if in a repo)
|
||||
- Relevant memory items based on prompt keywords
|
||||
- Pending decisions needing attention
|
||||
|
||||
Output goes to stdout and is added to Claude's context.
|
||||
Keep this fast (<5s) to not slow down prompts.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Paths
|
||||
STATE_DIR = Path.home() / ".claude/state/personal-assistant"
|
||||
MEMORY_DIR = STATE_DIR / "memory"
|
||||
|
||||
|
||||
def get_time_context() -> str:
|
||||
"""Get time-aware greeting context."""
|
||||
hour = datetime.now().hour
|
||||
if 5 <= hour < 12:
|
||||
period = "morning"
|
||||
elif 12 <= hour < 17:
|
||||
period = "afternoon"
|
||||
elif 17 <= hour < 21:
|
||||
period = "evening"
|
||||
else:
|
||||
period = "night"
|
||||
|
||||
return f"Current time: {datetime.now().strftime('%Y-%m-%d %H:%M')} ({period})"
|
||||
|
||||
|
||||
def get_git_context(cwd: str) -> str | None:
|
||||
"""Get current git branch if in a repo."""
|
||||
if not cwd:
|
||||
return None
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
cwd=cwd
|
||||
)
|
||||
if result.returncode == 0:
|
||||
branch = result.stdout.strip()
|
||||
if branch:
|
||||
return f"Git branch: {branch}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_relevant_memory(prompt: str, limit: int = 3) -> list[str]:
|
||||
"""Find memory items relevant to the prompt."""
|
||||
relevant = []
|
||||
prompt_lower = prompt.lower()
|
||||
|
||||
# Keywords to look for
|
||||
keywords = set(re.findall(r'\b\w{4,}\b', prompt_lower))
|
||||
|
||||
if not keywords:
|
||||
return relevant
|
||||
|
||||
# Check each memory file
|
||||
for memory_file in ["decisions.json", "preferences.json", "projects.json"]:
|
||||
path = MEMORY_DIR / memory_file
|
||||
if not path.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
for item in data.get("items", []):
|
||||
content = item.get("content", "").lower()
|
||||
context = item.get("context", "").lower()
|
||||
|
||||
# Check for keyword matches
|
||||
item_words = set(re.findall(r'\b\w{4,}\b', content + " " + context))
|
||||
matches = keywords & item_words
|
||||
|
||||
if len(matches) >= 2: # Require at least 2 matching keywords
|
||||
category = memory_file.replace(".json", "").rstrip("s")
|
||||
relevant.append(f"[{category}] {item.get('content', '')}")
|
||||
|
||||
if len(relevant) >= limit:
|
||||
return relevant
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return relevant
|
||||
|
||||
|
||||
def get_pending_decisions(limit: int = 2) -> list[str]:
|
||||
"""Get recent pending decisions."""
|
||||
pending = []
|
||||
decisions_path = MEMORY_DIR / "decisions.json"
|
||||
|
||||
if not decisions_path.exists():
|
||||
return pending
|
||||
|
||||
try:
|
||||
with open(decisions_path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Get most recent decisions (they might need follow-up)
|
||||
items = data.get("items", [])
|
||||
for item in items[-limit:]:
|
||||
if item.get("status") == "pending":
|
||||
pending.append(f"Pending: {item.get('content', '')}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return pending
|
||||
|
||||
|
||||
def main():
|
||||
# Read hook input from stdin
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
|
||||
prompt = input_data.get("prompt", "")
|
||||
cwd = input_data.get("cwd", "")
|
||||
|
||||
# Skip context injection for very short prompts (likely commands)
|
||||
if len(prompt) < 10:
|
||||
return
|
||||
|
||||
# Gather context
|
||||
context_parts = []
|
||||
|
||||
# Time context (always include)
|
||||
context_parts.append(get_time_context())
|
||||
|
||||
# Git context (if in a repo)
|
||||
git_ctx = get_git_context(cwd)
|
||||
if git_ctx:
|
||||
context_parts.append(git_ctx)
|
||||
|
||||
# Relevant memory (if prompt has substance)
|
||||
if len(prompt) > 20:
|
||||
relevant = get_relevant_memory(prompt)
|
||||
if relevant:
|
||||
context_parts.append("Relevant memory:")
|
||||
context_parts.extend(f" - {item}" for item in relevant)
|
||||
|
||||
# Pending decisions (occasionally remind)
|
||||
pending = get_pending_decisions()
|
||||
if pending:
|
||||
context_parts.extend(pending)
|
||||
|
||||
# Output context (will be injected into Claude's context)
|
||||
if context_parts:
|
||||
print("\n".join(context_parts))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
# Session end hook - triggers summarization of the conversation
|
||||
# Receives JSON via stdin with session_id, transcript_path, reason
|
||||
#
|
||||
# Uses Claude CLI with subscription credentials for LLM extraction.
|
||||
# Heuristic extraction (paths, facts) always runs.
|
||||
# LLM extraction (decisions, preferences) runs if claude CLI is available.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LOG_FILE="${HOME}/.claude/logs/session-end.log"
|
||||
|
||||
# Ensure log directory exists
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
|
||||
log() {
|
||||
echo "[$(date -Iseconds)] $*" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Read JSON input from stdin
|
||||
INPUT=$(cat)
|
||||
|
||||
# Parse JSON fields
|
||||
SESSION_ID=$(echo "$INPUT" | python3 -c "import sys, json; print(json.load(sys.stdin).get('session_id', ''))" 2>/dev/null || echo "")
|
||||
TRANSCRIPT_PATH=$(echo "$INPUT" | python3 -c "import sys, json; print(json.load(sys.stdin).get('transcript_path', ''))" 2>/dev/null || echo "")
|
||||
REASON=$(echo "$INPUT" | python3 -c "import sys, json; print(json.load(sys.stdin).get('reason', ''))" 2>/dev/null || echo "")
|
||||
|
||||
log "SessionEnd triggered: session=$SESSION_ID reason=$REASON"
|
||||
|
||||
# Clear guardrail session allowlist (confirmations don't persist across sessions)
|
||||
GUARDRAIL_SESSION="${HOME}/.claude/state/guardrail-session.json"
|
||||
if [[ -f "$GUARDRAIL_SESSION" ]]; then
|
||||
rm -f "$GUARDRAIL_SESSION"
|
||||
log "Cleared guardrail session allowlist"
|
||||
fi
|
||||
|
||||
# Validate required fields
|
||||
if [[ -z "$SESSION_ID" || -z "$TRANSCRIPT_PATH" ]]; then
|
||||
log "ERROR: Missing session_id or transcript_path"
|
||||
exit 0 # Exit cleanly - don't break session exit
|
||||
fi
|
||||
|
||||
# Check if transcript exists
|
||||
if [[ ! -f "$TRANSCRIPT_PATH" ]]; then
|
||||
log "ERROR: Transcript not found at $TRANSCRIPT_PATH"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run summarization script in background to not block session exit
|
||||
# The script will handle its own error logging
|
||||
nohup python3 "${SCRIPT_DIR}/summarize-transcript.py" \
|
||||
--session-id "$SESSION_ID" \
|
||||
--transcript "$TRANSCRIPT_PATH" \
|
||||
--reason "$REASON" \
|
||||
>> "$LOG_FILE" 2>&1 &
|
||||
|
||||
log "Summarization started in background (PID: $!)"
|
||||
|
||||
# Return success - don't block session exit
|
||||
exit 0
|
||||
@@ -32,12 +32,24 @@ with open('${PA_DIR}/memory/decisions.json') as f:
|
||||
" 2>/dev/null || echo "0")
|
||||
fi
|
||||
|
||||
# Check external mode
|
||||
EXTERNAL_MODE="disabled"
|
||||
if [[ -f "${STATE_DIR}/external-mode.json" ]]; then
|
||||
EXTERNAL_ENABLED=$(jq -r '.enabled // false' "${STATE_DIR}/external-mode.json" 2>/dev/null || echo "false")
|
||||
if [[ "${EXTERNAL_ENABLED}" == "true" ]]; then
|
||||
EXTERNAL_MODE="enabled"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Output context as system reminder format
|
||||
echo "SessionStart:Callback hook success: Success"
|
||||
echo "SessionStart:resume hook success: Success"
|
||||
|
||||
# Add additional context if there's something noteworthy
|
||||
if [[ "${UNSUMMARIZED}" -gt 0 || "${PENDING_DECISIONS}" -gt 0 ]]; then
|
||||
if [[ "${UNSUMMARIZED}" -gt 0 || "${PENDING_DECISIONS}" -gt 0 || "${EXTERNAL_MODE}" == "enabled" ]]; then
|
||||
echo "SessionStart hook additional context: "
|
||||
if [[ "${EXTERNAL_MODE}" == "enabled" ]]; then
|
||||
echo "- EXTERNAL MODE ACTIVE: All requests routed to external LLMs"
|
||||
fi
|
||||
if [[ "${UNSUMMARIZED}" -gt 0 ]]; then
|
||||
echo "- ${UNSUMMARIZED} unsummarized session(s) available for review"
|
||||
fi
|
||||
|
||||
Executable
+406
@@ -0,0 +1,406 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Summarize a Claude Code session transcript and extract key information to memory.
|
||||
|
||||
This script:
|
||||
1. Loads the transcript from Claude's storage
|
||||
2. Checks if session is substantive (>= 3 user messages)
|
||||
3. Extracts facts/paths via heuristics
|
||||
4. Uses Claude CLI (with subscription auth) for decisions/preferences if substantive
|
||||
5. Updates memory files and marks session as summarized
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Paths
|
||||
STATE_DIR = Path.home() / ".claude/state/personal-assistant"
|
||||
MEMORY_DIR = STATE_DIR / "memory"
|
||||
HISTORY_INDEX = STATE_DIR / "history/index.json"
|
||||
|
||||
# Memory files
|
||||
MEMORY_FILES = {
|
||||
"decisions": MEMORY_DIR / "decisions.json",
|
||||
"preferences": MEMORY_DIR / "preferences.json",
|
||||
"projects": MEMORY_DIR / "projects.json",
|
||||
"facts": MEMORY_DIR / "facts.json",
|
||||
}
|
||||
|
||||
# Minimum threshold for substantive sessions
|
||||
MIN_USER_MESSAGES = 3
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
"""Log with timestamp."""
|
||||
print(f"[{datetime.now().isoformat()}] {msg}", file=sys.stderr)
|
||||
|
||||
|
||||
def load_transcript(path: str) -> list[dict]:
|
||||
"""Load transcript from jsonl file."""
|
||||
messages = []
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
messages.append(json.loads(line))
|
||||
except Exception as e:
|
||||
log(f"Error loading transcript: {e}")
|
||||
return messages
|
||||
|
||||
|
||||
def count_user_messages(transcript: list[dict]) -> int:
|
||||
"""Count the number of user messages in transcript."""
|
||||
count = 0
|
||||
for entry in transcript:
|
||||
# Claude Code format: type is "user" or "assistant" at top level
|
||||
if entry.get("type") == "user":
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def extract_conversation_text(transcript: list[dict]) -> str:
|
||||
"""Extract readable conversation text from transcript."""
|
||||
parts = []
|
||||
for entry in transcript:
|
||||
entry_type = entry.get("type", "")
|
||||
|
||||
# Skip non-message entries (like queue-operation)
|
||||
if entry_type not in ("user", "assistant"):
|
||||
continue
|
||||
|
||||
message = entry.get("message", {})
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
|
||||
role = message.get("role", entry_type)
|
||||
content = message.get("content", "")
|
||||
|
||||
# Handle different content formats
|
||||
if isinstance(content, list):
|
||||
# Assistant messages have content as array of blocks
|
||||
text_parts = []
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
if block.get("type") == "text":
|
||||
text_parts.append(block.get("text", ""))
|
||||
elif block.get("type") == "tool_use":
|
||||
text_parts.append(f"[Tool: {block.get('name', 'unknown')}]")
|
||||
elif isinstance(block, str):
|
||||
text_parts.append(block)
|
||||
content = "\n".join(text_parts)
|
||||
elif isinstance(content, str):
|
||||
# User messages have content as string
|
||||
pass
|
||||
else:
|
||||
continue
|
||||
|
||||
if content:
|
||||
parts.append(f"[{role}]: {content[:2000]}") # Truncate long messages
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def heuristic_extraction(transcript: list[dict]) -> dict[str, list[dict]]:
|
||||
"""Extract simple facts and paths using heuristics."""
|
||||
results = {"projects": [], "facts": []}
|
||||
|
||||
conversation = extract_conversation_text(transcript)
|
||||
|
||||
# Extract file paths mentioned
|
||||
path_pattern = r'(?:/[\w.-]+)+(?:/[\w.-]*)?'
|
||||
paths = set(re.findall(path_pattern, conversation))
|
||||
|
||||
# Filter to likely project paths
|
||||
project_paths = set()
|
||||
for p in paths:
|
||||
if any(x in p for x in ['/home/', '/Users/', '/.claude/', '/projects/']):
|
||||
if not any(x in p for x in ['/proc/', '/sys/', '/dev/', '/tmp/']):
|
||||
project_paths.add(p)
|
||||
|
||||
# Add unique project paths as context
|
||||
for path in list(project_paths)[:5]: # Limit to 5 paths
|
||||
results["projects"].append({
|
||||
"content": f"Worked with path: {path}",
|
||||
"context": "File path referenced in session"
|
||||
})
|
||||
|
||||
# Extract tool/environment facts
|
||||
tool_patterns = [
|
||||
(r'using\s+([\w-]+)\s+version\s+([\d.]+)', "Tool version: {0} {1}"),
|
||||
(r'(python|node|npm|pip)\s+.*?([\d.]+)', "Runtime: {0} {1}"),
|
||||
]
|
||||
|
||||
for pattern, template in tool_patterns:
|
||||
matches = re.findall(pattern, conversation, re.IGNORECASE)
|
||||
for match in matches[:2]: # Limit matches
|
||||
results["facts"].append({
|
||||
"content": template.format(*match),
|
||||
"context": "Environment fact from session"
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def llm_extraction(conversation_text: str, session_id: str) -> dict[str, list[dict]]:
|
||||
"""Use Claude CLI to extract decisions and preferences."""
|
||||
results = {"decisions": [], "preferences": []}
|
||||
|
||||
# Check if claude CLI is available
|
||||
claude_path = subprocess.run(
|
||||
["which", "claude"], capture_output=True, text=True
|
||||
).stdout.strip()
|
||||
|
||||
if not claude_path:
|
||||
log("Claude CLI not found, skipping LLM extraction")
|
||||
return results
|
||||
|
||||
prompt = f"""Analyze this conversation excerpt and extract key information.
|
||||
|
||||
CONVERSATION:
|
||||
{conversation_text[:15000]}
|
||||
|
||||
Extract and return a JSON object with:
|
||||
1. "decisions": List of decisions made (choices, directions taken, approaches selected)
|
||||
2. "preferences": List of user preferences learned (likes, dislikes, preferred approaches)
|
||||
|
||||
For each item include:
|
||||
- "content": Brief description (1 sentence)
|
||||
- "context": Why this matters or additional context
|
||||
|
||||
Only include genuinely significant items. Skip trivial or obvious things.
|
||||
Return valid JSON only, no markdown formatting.
|
||||
|
||||
Example format:
|
||||
{{"decisions": [{{"content": "Use PostgreSQL for the database", "context": "Chosen for JSONB support"}}], "preferences": [{{"content": "Prefers explicit type annotations", "context": "For code clarity"}}]}}"""
|
||||
|
||||
try:
|
||||
# Use claude CLI in print mode with haiku for cost efficiency
|
||||
result = subprocess.run(
|
||||
[
|
||||
claude_path, "-p",
|
||||
"--model", "haiku",
|
||||
"--no-session-persistence",
|
||||
prompt
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
cwd=str(Path.home()) # Run from home to avoid project context
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
log(f"Claude CLI failed: {result.stderr[:500]}")
|
||||
return results
|
||||
|
||||
response_text = result.stdout.strip()
|
||||
|
||||
# Try to extract JSON from response
|
||||
try:
|
||||
# Handle potential markdown code blocks
|
||||
if "```" in response_text:
|
||||
json_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', response_text, re.DOTALL)
|
||||
if json_match:
|
||||
response_text = json_match.group(1)
|
||||
|
||||
data = json.loads(response_text)
|
||||
|
||||
for key in ["decisions", "preferences"]:
|
||||
if key in data and isinstance(data[key], list):
|
||||
for item in data[key][:5]: # Limit to 5 per category
|
||||
if isinstance(item, dict) and "content" in item:
|
||||
results[key].append({
|
||||
"content": item["content"],
|
||||
"context": item.get("context", "")
|
||||
})
|
||||
except json.JSONDecodeError as e:
|
||||
log(f"Failed to parse LLM response as JSON: {e}")
|
||||
log(f"Response was: {response_text[:500]}")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
log("Claude CLI timed out")
|
||||
except Exception as e:
|
||||
log(f"LLM extraction error: {e}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def load_memory_file(path: Path) -> dict:
|
||||
"""Load a memory file, creating default structure if needed."""
|
||||
if path.exists():
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Default structure
|
||||
return {
|
||||
"version": "1.0",
|
||||
"description": f"{path.stem.title()} extracted from sessions",
|
||||
"items": []
|
||||
}
|
||||
|
||||
|
||||
def save_memory_file(path: Path, data: dict) -> None:
|
||||
"""Save a memory file."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def add_to_memory(category: str, items: list[dict], session_id: str) -> int:
|
||||
"""Add items to a memory category. Returns count of items added."""
|
||||
if not items:
|
||||
return 0
|
||||
|
||||
path = MEMORY_FILES.get(category)
|
||||
if not path:
|
||||
return 0
|
||||
|
||||
data = load_memory_file(path)
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Check for duplicates based on content
|
||||
existing_content = {item.get("content", "").lower() for item in data.get("items", [])}
|
||||
|
||||
added = 0
|
||||
for item in items:
|
||||
content = item.get("content", "")
|
||||
if content.lower() not in existing_content:
|
||||
data["items"].append({
|
||||
"id": str(uuid.uuid4()),
|
||||
"date": today,
|
||||
"content": content,
|
||||
"context": item.get("context", ""),
|
||||
"session": session_id
|
||||
})
|
||||
existing_content.add(content.lower())
|
||||
added += 1
|
||||
|
||||
if added > 0:
|
||||
save_memory_file(path, data)
|
||||
|
||||
return added
|
||||
|
||||
|
||||
def update_history_index(session_id: str, transcript_path: str, topics: list[str]) -> None:
|
||||
"""Mark session as summarized in history index."""
|
||||
if not HISTORY_INDEX.exists():
|
||||
log(f"History index not found: {HISTORY_INDEX}")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(HISTORY_INDEX) as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Find and update the session
|
||||
for session in data.get("sessions", []):
|
||||
if session.get("id") == session_id:
|
||||
session["summarized"] = True
|
||||
session["transcript_path"] = transcript_path
|
||||
session["topics"] = topics[:5] # Limit topics
|
||||
session["summarized_at"] = datetime.now().isoformat()
|
||||
break
|
||||
|
||||
with open(HISTORY_INDEX, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
log(f"Updated history index for session {session_id}")
|
||||
|
||||
except Exception as e:
|
||||
log(f"Error updating history index: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Summarize a Claude Code session")
|
||||
parser.add_argument("--session-id", required=True, help="Session ID")
|
||||
parser.add_argument("--transcript", required=True, help="Path to transcript file")
|
||||
parser.add_argument("--reason", default="", help="Session end reason")
|
||||
args = parser.parse_args()
|
||||
|
||||
log(f"Starting summarization for session {args.session_id}")
|
||||
|
||||
# Load transcript
|
||||
transcript = load_transcript(args.transcript)
|
||||
if not transcript:
|
||||
log("Empty or invalid transcript, skipping")
|
||||
return
|
||||
|
||||
# Check threshold
|
||||
user_msg_count = count_user_messages(transcript)
|
||||
log(f"Found {user_msg_count} user messages")
|
||||
|
||||
if user_msg_count < MIN_USER_MESSAGES:
|
||||
log(f"Session below threshold ({MIN_USER_MESSAGES}), marking as summarized without extraction")
|
||||
update_history_index(args.session_id, args.transcript, ["trivial"])
|
||||
return
|
||||
|
||||
# Extract conversation text
|
||||
conversation_text = extract_conversation_text(transcript)
|
||||
|
||||
# Heuristic extraction (always run)
|
||||
log("Running heuristic extraction...")
|
||||
heuristic_results = heuristic_extraction(transcript)
|
||||
|
||||
# LLM extraction (for substantive sessions)
|
||||
log("Running LLM extraction...")
|
||||
llm_results = llm_extraction(conversation_text, args.session_id)
|
||||
|
||||
# Combine results
|
||||
all_results = {
|
||||
"decisions": llm_results.get("decisions", []),
|
||||
"preferences": llm_results.get("preferences", []),
|
||||
"projects": heuristic_results.get("projects", []),
|
||||
"facts": heuristic_results.get("facts", []),
|
||||
}
|
||||
|
||||
# Save to memory files
|
||||
total_added = 0
|
||||
topics = []
|
||||
for category, items in all_results.items():
|
||||
count = add_to_memory(category, items, args.session_id)
|
||||
total_added += count
|
||||
if count > 0:
|
||||
topics.append(category)
|
||||
log(f"Added {count} items to {category}")
|
||||
|
||||
# Update history index
|
||||
update_history_index(args.session_id, args.transcript, topics)
|
||||
|
||||
log(f"Summarization complete: {total_added} total items added")
|
||||
|
||||
# Reindex RAG if we added items
|
||||
if total_added > 0:
|
||||
log("Triggering RAG reindex...")
|
||||
try:
|
||||
reindex_result = subprocess.run(
|
||||
[
|
||||
str(Path.home() / ".claude/skills/rag-search/venv/bin/python"),
|
||||
str(Path.home() / ".claude/skills/rag-search/scripts/index_personal.py"),
|
||||
"--quiet"
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120
|
||||
)
|
||||
if reindex_result.returncode == 0:
|
||||
log("RAG reindex completed successfully")
|
||||
else:
|
||||
log(f"RAG reindex failed: {reindex_result.stderr[:200]}")
|
||||
except subprocess.TimeoutExpired:
|
||||
log("RAG reindex timed out after 120s")
|
||||
except Exception as e:
|
||||
log(f"RAG reindex error: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Agent delegation helper. Routes to external or Claude based on mode.
|
||||
|
||||
Usage:
|
||||
delegate.py --tier sonnet -p "prompt"
|
||||
delegate.py --tier opus -p "complex reasoning task" --json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
STATE_DIR = Path.home() / ".claude/state"
|
||||
ROUTER_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def is_external_mode() -> bool:
|
||||
"""Check if external-only mode is enabled."""
|
||||
mode_file = STATE_DIR / "external-mode.json"
|
||||
if mode_file.exists():
|
||||
with open(mode_file) as f:
|
||||
data = json.load(f)
|
||||
return data.get("enabled", False)
|
||||
return False
|
||||
|
||||
|
||||
def get_external_model(tier: str) -> str:
|
||||
"""Get the external model equivalent for a Claude tier."""
|
||||
policy_file = STATE_DIR / "model-policy.json"
|
||||
with open(policy_file) as f:
|
||||
policy = json.load(f)
|
||||
mapping = policy.get("claude_to_external_map", {})
|
||||
if tier not in mapping:
|
||||
raise ValueError(f"No external mapping for tier: {tier}")
|
||||
return mapping[tier]
|
||||
|
||||
|
||||
def delegate(tier: str, prompt: str, use_json: bool = False) -> str:
|
||||
"""
|
||||
Delegate to appropriate model based on mode.
|
||||
|
||||
Args:
|
||||
tier: Claude tier (opus, sonnet, haiku)
|
||||
prompt: The prompt text
|
||||
use_json: Return JSON output
|
||||
|
||||
Returns:
|
||||
Model response as string
|
||||
"""
|
||||
if is_external_mode():
|
||||
# Use external model
|
||||
model = get_external_model(tier)
|
||||
invoke_script = ROUTER_DIR / "invoke.py"
|
||||
|
||||
cmd = [sys.executable, str(invoke_script), "--model", model, "-p", prompt]
|
||||
if use_json:
|
||||
cmd.append("--json")
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"External invoke failed: {result.stderr}")
|
||||
|
||||
return result.stdout.strip()
|
||||
else:
|
||||
# Use Claude
|
||||
cmd = ["claude", "--print", "--model", tier, prompt]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Claude failed: {result.stderr}")
|
||||
|
||||
response = result.stdout.strip()
|
||||
|
||||
if use_json:
|
||||
return json.dumps({
|
||||
"model": f"claude/{tier}",
|
||||
"response": response,
|
||||
"success": True
|
||||
}, indent=2)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Delegate to Claude or external model based on mode"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tier",
|
||||
required=True,
|
||||
choices=["opus", "sonnet", "haiku"],
|
||||
help="Claude tier (maps to external equivalent when in external mode)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p", "--prompt",
|
||||
required=True,
|
||||
help="Prompt text"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
result = delegate(args.tier, args.prompt, args.json)
|
||||
print(result)
|
||||
except Exception as e:
|
||||
if args.json:
|
||||
print(json.dumps({"error": str(e), "success": False}, indent=2))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Invoke external LLM via configured provider.
|
||||
|
||||
Usage:
|
||||
invoke.py --model copilot/gpt-5.2 -p "prompt"
|
||||
invoke.py --task reasoning -p "prompt"
|
||||
invoke.py --task code-generation -p "prompt" --json
|
||||
|
||||
Model selection priority:
|
||||
1. Explicit --model flag
|
||||
2. Task-based routing (--task flag)
|
||||
3. Default from policy
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
STATE_DIR = Path.home() / ".claude/state"
|
||||
ROUTER_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def load_policy() -> dict:
|
||||
"""Load model policy from state file."""
|
||||
policy_file = STATE_DIR / "model-policy.json"
|
||||
with open(policy_file) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def resolve_model(args: argparse.Namespace, policy: dict) -> str:
|
||||
"""Determine which model to use based on args and policy."""
|
||||
if args.model:
|
||||
return args.model
|
||||
if args.task and args.task in policy.get("task_routing", {}):
|
||||
return policy["task_routing"][args.task]
|
||||
return policy.get("task_routing", {}).get("default", "copilot/sonnet-4.5")
|
||||
|
||||
|
||||
def invoke(model: str, prompt: str, policy: dict, timeout: int = 600) -> str:
|
||||
"""Invoke the appropriate provider for the given model."""
|
||||
external_models = policy.get("external_models", {})
|
||||
|
||||
if model not in external_models:
|
||||
raise ValueError(f"Unknown model: {model}. Available: {list(external_models.keys())}")
|
||||
|
||||
model_config = external_models[model]
|
||||
cli = model_config["cli"]
|
||||
cli_args = model_config.get("cli_args", [])
|
||||
|
||||
# Import and invoke appropriate provider
|
||||
if cli == "opencode":
|
||||
sys.path.insert(0, str(ROUTER_DIR))
|
||||
from providers.opencode import invoke as opencode_invoke
|
||||
return opencode_invoke(cli_args, prompt, timeout=timeout)
|
||||
elif cli == "gemini":
|
||||
sys.path.insert(0, str(ROUTER_DIR))
|
||||
from providers.gemini import invoke as gemini_invoke
|
||||
return gemini_invoke(cli_args, prompt, timeout=timeout)
|
||||
else:
|
||||
raise ValueError(f"Unknown CLI: {cli}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Invoke external LLM via configured provider"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p", "--prompt",
|
||||
required=True,
|
||||
help="Prompt text"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
help="Explicit model (e.g., copilot/gpt-5.2)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--task",
|
||||
choices=["reasoning", "code-generation", "long-context", "general"],
|
||||
help="Task type for automatic model routing"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON with model info"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=int,
|
||||
default=600,
|
||||
help="Timeout in seconds (default: 600)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
policy = load_policy()
|
||||
model = resolve_model(args, policy)
|
||||
result = invoke(model, args.prompt, policy, timeout=args.timeout)
|
||||
|
||||
if args.json:
|
||||
output = {
|
||||
"model": model,
|
||||
"response": result,
|
||||
"success": True
|
||||
}
|
||||
print(json.dumps(output, indent=2))
|
||||
else:
|
||||
print(result)
|
||||
|
||||
except Exception as e:
|
||||
if args.json:
|
||||
output = {
|
||||
"model": args.model or "unknown",
|
||||
"error": str(e),
|
||||
"success": False
|
||||
}
|
||||
print(json.dumps(output, indent=2))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Gemini CLI wrapper for Google models."""
|
||||
|
||||
import subprocess
|
||||
from typing import List
|
||||
|
||||
|
||||
def invoke(cli_args: List[str], prompt: str, timeout: int = 300) -> str:
|
||||
"""
|
||||
Invoke gemini CLI with given args and prompt.
|
||||
|
||||
Args:
|
||||
cli_args: Model args like ["-m", "gemini-3-pro"]
|
||||
prompt: The prompt text
|
||||
timeout: Timeout in seconds (default 5 minutes)
|
||||
|
||||
Returns:
|
||||
Model response as string
|
||||
|
||||
Raises:
|
||||
RuntimeError: If gemini CLI fails
|
||||
TimeoutError: If request exceeds timeout
|
||||
"""
|
||||
cmd = ["gemini"] + cli_args + ["-p", prompt]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise TimeoutError(f"gemini timed out after {timeout}s")
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"gemini failed (exit {result.returncode}): {result.stderr}")
|
||||
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Quick test
|
||||
import sys
|
||||
if len(sys.argv) > 1:
|
||||
response = invoke(["-m", "gemini-3-pro"], sys.argv[1])
|
||||
print(response)
|
||||
else:
|
||||
print("Usage: gemini.py 'prompt'")
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OpenCode CLI wrapper for GitHub Copilot, Z.AI, and other providers."""
|
||||
|
||||
import subprocess
|
||||
from typing import List
|
||||
|
||||
# OpenCode binary path (linuxbrew installation)
|
||||
OPENCODE_BIN = "/home/linuxbrew/.linuxbrew/bin/opencode"
|
||||
|
||||
|
||||
def invoke(cli_args: List[str], prompt: str, timeout: int = 300) -> str:
|
||||
"""
|
||||
Invoke opencode CLI with given args and prompt.
|
||||
|
||||
Args:
|
||||
cli_args: Model args like ["-m", "github-copilot/gpt-5.2"]
|
||||
prompt: The prompt text
|
||||
timeout: Timeout in seconds (default 5 minutes)
|
||||
|
||||
Returns:
|
||||
Model response as string
|
||||
|
||||
Raises:
|
||||
RuntimeError: If opencode CLI fails
|
||||
TimeoutError: If request exceeds timeout
|
||||
|
||||
Example invocation:
|
||||
opencode run -m github-copilot/gpt-5.2 "Hello world"
|
||||
"""
|
||||
# Build command: opencode run -m MODEL "prompt"
|
||||
cmd = [OPENCODE_BIN, "run"] + cli_args + [prompt]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise TimeoutError(f"opencode timed out after {timeout}s")
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"opencode failed (exit {result.returncode}): {result.stderr}")
|
||||
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Quick test
|
||||
import sys
|
||||
if len(sys.argv) > 1:
|
||||
response = invoke(["-m", "github-copilot/gpt-5.2"], sys.argv[1])
|
||||
print(response)
|
||||
else:
|
||||
print("Usage: opencode.py 'prompt'")
|
||||
Executable
+98
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Toggle external-only mode.
|
||||
|
||||
Usage:
|
||||
toggle.py on [--reason "user requested"]
|
||||
toggle.py off
|
||||
toggle.py status
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
STATE_FILE = Path.home() / ".claude/state/external-mode.json"
|
||||
|
||||
|
||||
def load_state() -> dict:
|
||||
"""Load current state."""
|
||||
if STATE_FILE.exists():
|
||||
with open(STATE_FILE) as f:
|
||||
return json.load(f)
|
||||
return {"enabled": False, "activated_at": None, "reason": None}
|
||||
|
||||
|
||||
def save_state(state: dict):
|
||||
"""Save state to file."""
|
||||
with open(STATE_FILE, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
|
||||
def enable(reason: Optional[str] = None):
|
||||
"""Enable external-only mode."""
|
||||
state = {
|
||||
"enabled": True,
|
||||
"activated_at": datetime.now().isoformat(),
|
||||
"reason": reason or "user-requested"
|
||||
}
|
||||
save_state(state)
|
||||
print("External-only mode ENABLED")
|
||||
print(f" Activated: {state['activated_at']}")
|
||||
print(f" Reason: {state['reason']}")
|
||||
print("\nAll agent requests will now use external LLMs.")
|
||||
print("Run 'toggle.py off' or '/pa --external off' to disable.")
|
||||
|
||||
|
||||
def disable():
|
||||
"""Disable external-only mode."""
|
||||
state = {
|
||||
"enabled": False,
|
||||
"activated_at": None,
|
||||
"reason": None
|
||||
}
|
||||
save_state(state)
|
||||
print("External-only mode DISABLED")
|
||||
print("\nAll agent requests will now use Claude.")
|
||||
|
||||
|
||||
def status():
|
||||
"""Show current mode status."""
|
||||
state = load_state()
|
||||
if state.get("enabled"):
|
||||
print("External-only mode: ENABLED")
|
||||
print(f" Activated: {state.get('activated_at', 'unknown')}")
|
||||
print(f" Reason: {state.get('reason', 'unknown')}")
|
||||
else:
|
||||
print("External-only mode: DISABLED")
|
||||
print(" Using Claude for all requests.")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Toggle external-only mode")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# on command
|
||||
on_parser = subparsers.add_parser("on", help="Enable external-only mode")
|
||||
on_parser.add_argument("--reason", help="Reason for enabling")
|
||||
|
||||
# off command
|
||||
subparsers.add_parser("off", help="Disable external-only mode")
|
||||
|
||||
# status command
|
||||
subparsers.add_parser("status", help="Show current mode")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "on":
|
||||
enable(args.reason)
|
||||
elif args.command == "off":
|
||||
disable()
|
||||
elif args.command == "status":
|
||||
status()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,74 @@
|
||||
# Plans
|
||||
|
||||
Implementation plans for features, enhancements, and investigations.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
plans/
|
||||
├── index.json # Status registry (source of truth)
|
||||
├── README.md # This file
|
||||
└── *.md # Individual plan files
|
||||
```
|
||||
|
||||
## Plan Naming
|
||||
|
||||
- **Dated plans**: `YYYY-MM-DD-descriptive-name.md` (design docs)
|
||||
- **Generated names**: `adjective-verb-scientist.md` (brainstorming outputs)
|
||||
|
||||
## Status Registry (index.json)
|
||||
|
||||
Central tracking for all plans:
|
||||
|
||||
```json
|
||||
{
|
||||
"plan-name": {
|
||||
"title": "Human readable title",
|
||||
"status": "pending|implemented|partial|abandoned|superseded",
|
||||
"created": "YYYY-MM-DD",
|
||||
"implemented": "YYYY-MM-DD",
|
||||
"category": "feature|enhancement|bugfix|diagnostic|design",
|
||||
"notes": "Optional notes"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Status Values
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| `pending` | Not yet implemented |
|
||||
| `implemented` | Fully implemented |
|
||||
| `partial` | Partially implemented |
|
||||
| `abandoned` | Decided not to implement |
|
||||
| `superseded` | Replaced by another plan |
|
||||
|
||||
### Categories
|
||||
|
||||
| Category | Meaning |
|
||||
|----------|---------|
|
||||
| `feature` | New capability |
|
||||
| `enhancement` | Improve existing feature |
|
||||
| `bugfix` | Fix an issue |
|
||||
| `diagnostic` | One-time investigation |
|
||||
| `design` | Design document for reference |
|
||||
|
||||
## Querying Plans
|
||||
|
||||
```bash
|
||||
# List pending plans
|
||||
jq -r '.plans | to_entries[] | select(.value.status == "pending") | .key' index.json
|
||||
|
||||
# List by category
|
||||
jq '.plans | to_entries[] | select(.value.category == "feature")' index.json
|
||||
|
||||
# Count by status
|
||||
jq '.plans | to_entries | group_by(.value.status) | map({status: .[0].value.status, count: length})' index.json
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Create plan**: Write `plans/plan-name.md`
|
||||
2. **Register**: Add entry to `index.json` with `status: "pending"`
|
||||
3. **Implement**: Execute the plan
|
||||
4. **Update**: Set `status: "implemented"` and add `implemented` date
|
||||
@@ -0,0 +1,798 @@
|
||||
# Plan: Transpose Claude Code Setup to OpenCode (Parallel)
|
||||
|
||||
## Handoff Summary
|
||||
|
||||
**Goal**: Set up OpenCode in parallel with Claude Code, sharing state files and syncing agents/skills.
|
||||
|
||||
**Status**: ✅ **IMPLEMENTATION COMPLETE** (2026-01-07)
|
||||
|
||||
### Key Decisions Made
|
||||
|
||||
1. **Use built-in `build` agent** as primary (not porting `personal-assistant`)
|
||||
2. **All agents synced as subagents** (SKIP_AGENTS kept empty for flexibility)
|
||||
3. **Model inheritance** - subagents use runtime-selected model
|
||||
4. **Claude Code is source of truth** - OpenCode references state files via `instructions`
|
||||
5. **No JSON minification** needed (files too small, added to future considerations as fc-047)
|
||||
|
||||
### What Was Completed
|
||||
|
||||
| Step | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| 1 | ✅ | Backups created (Jan 7 12:01) |
|
||||
| 2 | ✅ | Sync script enhanced (mode:subagent, model removal) |
|
||||
| 3 | ✅ | Sync run: 10 skills, 13 agents, 27 commands, 10 workflows |
|
||||
| 4 | ✅ | opencode.json updated (instructions, permissions) |
|
||||
| 5 | ✅ | Automated tests passed, manual TUI testing pending |
|
||||
| 6 | ✅ | README.md created (4.7KB), fc-047 added |
|
||||
| 7 | ⏳ | Iterate as needed |
|
||||
|
||||
### Critical Files
|
||||
|
||||
**Modified:**
|
||||
- `~/.config/opencode/scripts/claude_sync.py` ✅ - Added mode:subagent, model removal, skip logic
|
||||
- `~/.config/opencode/opencode.json` ✅ - Added instructions, permissions
|
||||
|
||||
**Created:**
|
||||
- `~/.config/opencode/README.md` ✅ - Documentation (4.7KB)
|
||||
|
||||
**Referenced (not copied):**
|
||||
- `~/.claude/CLAUDE.md`
|
||||
- `~/.claude/state/kb.json`
|
||||
- `~/.claude/state/personal-assistant/memory/*.json`
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
OpenCode (after implementation)
|
||||
├── Primary: build (built-in), plan (built-in)
|
||||
├── Subagents: @linux-sysadmin, @k8s-orchestrator, @code-reviewer, etc.
|
||||
├── Skills: auto-discovered from ~/.claude/skills/
|
||||
└── State: referenced via instructions from ~/.claude/state/
|
||||
```
|
||||
|
||||
### Start Command
|
||||
|
||||
```bash
|
||||
# Exit plan mode and begin implementation
|
||||
# Step 1: Backup
|
||||
BACKUP_DATE=$(date +%Y%m%d_%H%M%S)
|
||||
tar -czvf ~/.config/opencode-backup-$BACKUP_DATE.tar.gz -C ~/.config opencode/
|
||||
tar -czvf ~/opencode-home-backup-$BACKUP_DATE.tar.gz -C ~ .opencode/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Create a parallel OpenCode configuration that shares/reuses as much of the existing Claude Code infrastructure as possible, focusing on:
|
||||
1. **Skills/scripts execution** (highest priority)
|
||||
2. **Agent hierarchy/delegation** (second priority)
|
||||
3. **State persistence** (if low complexity)
|
||||
|
||||
## Key Discovery: Native Compatibility
|
||||
|
||||
OpenCode **natively supports Claude-compatible skill paths**:
|
||||
- `~/.claude/skills/<name>/SKILL.md` - Already supported!
|
||||
- This means your 11 existing skills can work with minimal changes
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Backup Existing OpenCode Setup
|
||||
|
||||
### Current State Discovered
|
||||
|
||||
OpenCode is already installed with substantial configuration:
|
||||
|
||||
**`~/.config/opencode/`** (main config):
|
||||
- `opencode.json` - Has `claude-sync` command already!
|
||||
- `agent/` - 3 custom agents (coding-expert, k8s-expert, tdd-enforcer)
|
||||
- `agents/` - 12 synced Claude Code agents (already converted!)
|
||||
- `skills/` - 10 skills (some synced, one symlink to morning-report)
|
||||
- `scripts/claude_sync.py` - Existing sync script!
|
||||
|
||||
**`~/.opencode/`** (alternate config):
|
||||
- `agent/` - 4 different agents (openagent, system-builder, etc.)
|
||||
- `command/` - 12 commands (commit, optimize, validate-repo, etc.)
|
||||
|
||||
### Backup Commands
|
||||
|
||||
```bash
|
||||
# Create timestamped backups
|
||||
BACKUP_DATE=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
# Backup ~/.config/opencode/
|
||||
tar -czvf ~/.config/opencode-backup-$BACKUP_DATE.tar.gz -C ~/.config opencode/
|
||||
|
||||
# Backup ~/.opencode/
|
||||
tar -czvf ~/opencode-home-backup-$BACKUP_DATE.tar.gz -C ~ .opencode/
|
||||
|
||||
# Verify backups
|
||||
ls -la ~/.config/opencode-backup-*.tar.gz ~/opencode-home-backup-*.tar.gz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Use Existing `claude_sync.py` Script
|
||||
|
||||
The existing sync script is **comprehensive** and handles:
|
||||
|
||||
| Category | Source | Destination | Transforms |
|
||||
|----------|--------|-------------|------------|
|
||||
| Skills | `~/.claude/skills/*/SKILL.md` | `~/.config/opencode/skills/*/SKILL.md` | `allowed-tools` → `metadata.claude_allowed_tools` |
|
||||
| Agents | `~/.claude/agents/*.md` | `~/.config/opencode/agents/*.md` | `tools: X, Y` → `tools: { x: true, y: true }` |
|
||||
| Commands | `~/.claude/commands/*.md` | `~/.config/opencode/claude/commands/*.md` | None |
|
||||
| Workflows | `~/.claude/workflows/*.yaml` | `~/.config/opencode/claude/workflows/*.yaml` | None |
|
||||
|
||||
### Sync Commands
|
||||
|
||||
```bash
|
||||
# Dry run - see what would change
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py --dry-run
|
||||
|
||||
# Actually sync
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py
|
||||
|
||||
# Clean stale files (dry run first)
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py --clean --dry-run
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py --clean --apply
|
||||
|
||||
# Sync specific category only
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py --only agents
|
||||
```
|
||||
|
||||
### Model Mapping Update Needed
|
||||
|
||||
Current script maps old models. May need to add:
|
||||
- `opus` → `anthropic/claude-opus-4`
|
||||
- `sonnet` → `anthropic/claude-sonnet-4-5`
|
||||
- `haiku` → `anthropic/claude-haiku-4-5`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1.5: OpenCode Optimization (NEW)
|
||||
|
||||
The current sync just copies/transforms files. It doesn't optimize for **how OpenCode works**.
|
||||
|
||||
### Key OpenCode Differences
|
||||
|
||||
| Concept | Claude Code | OpenCode | Optimization Needed |
|
||||
|---------|-------------|----------|---------------------|
|
||||
| **Agent hierarchy** | PA → MO → agents | Flat: primary + subagents | Add `mode` field |
|
||||
| **Agent invocation** | Delegation patterns | `@agent` mentions | Simplify prompts |
|
||||
| **Permissions** | Hooks + guardrails | `permission` config | Move to opencode.json |
|
||||
| **Model selection** | Per-agent in frontmatter | `model: inherit` option | Use inheritance |
|
||||
| **Auto-invocation** | Keyword triggers in registry | Rich `description` field | Enhance descriptions |
|
||||
|
||||
### Agent Mode Assignment
|
||||
|
||||
```yaml
|
||||
# PRIMARY - Use OpenCode's built-in agents
|
||||
build: (built-in) # Full access, default primary
|
||||
plan: (built-in) # Read-only analysis
|
||||
|
||||
# SKIP - Not needed in OpenCode's flat model
|
||||
personal-assistant: # Use built-in "build" instead
|
||||
master-orchestrator: # Intermediary not needed
|
||||
|
||||
# SUBAGENTS (invoked via @mention or Task tool)
|
||||
linux-sysadmin: mode: subagent
|
||||
k8s-orchestrator: mode: subagent
|
||||
k8s-diagnostician: mode: subagent
|
||||
argocd-operator: mode: subagent
|
||||
prometheus-analyst: mode: subagent
|
||||
git-operator: mode: subagent
|
||||
programmer-orchestrator: mode: subagent
|
||||
code-planner: mode: subagent
|
||||
code-implementer: mode: subagent
|
||||
code-reviewer: mode: subagent
|
||||
```
|
||||
|
||||
### Hierarchy Simplification
|
||||
|
||||
**Claude Code pattern** (complex, 3 layers):
|
||||
```
|
||||
User → Personal Assistant → Master Orchestrator → linux-sysadmin
|
||||
→ k8s-orchestrator → k8s-diagnostician
|
||||
```
|
||||
|
||||
**OpenCode pattern** (flat, 2 layers):
|
||||
```
|
||||
User → build (built-in) → @linux-sysadmin
|
||||
→ @k8s-orchestrator
|
||||
→ @k8s-diagnostician
|
||||
→ @code-reviewer
|
||||
→ etc.
|
||||
```
|
||||
|
||||
Benefits:
|
||||
- No custom primary agent to maintain
|
||||
- Built-in `build` agent is optimized for OpenCode
|
||||
- Built-in `plan` agent available for read-only analysis
|
||||
- Subagents invoked directly via @mention
|
||||
|
||||
### Sync Script Enhancements Needed
|
||||
|
||||
Update `claude_sync.py` to add:
|
||||
|
||||
```python
|
||||
# In transform_frontmatter() for agents:
|
||||
|
||||
# 1. Skip agents not needed in OpenCode's flat model
|
||||
SKIP_AGENTS = {"personal-assistant", "master-orchestrator"}
|
||||
|
||||
if name in SKIP_AGENTS:
|
||||
return None # Signal to skip this file
|
||||
|
||||
# 2. All synced agents become subagents (built-in build/plan are primary)
|
||||
frontmatter["mode"] = "subagent"
|
||||
|
||||
# 3. Use model inheritance (subagents use parent's model)
|
||||
frontmatter["model"] = "inherit"
|
||||
|
||||
# 4. Map explicit models if not using inherit
|
||||
MODEL_MAP = {
|
||||
"opus": "anthropic/claude-opus-4",
|
||||
"sonnet": "anthropic/claude-sonnet-4-5",
|
||||
"haiku": "anthropic/claude-haiku-4-5",
|
||||
}
|
||||
if frontmatter.get("model") in MODEL_MAP:
|
||||
frontmatter["model"] = MODEL_MAP[frontmatter["model"]]
|
||||
```
|
||||
|
||||
Also update `sync_tree()` to handle `None` return (skip file).
|
||||
|
||||
### Description Enhancement
|
||||
|
||||
OpenCode uses descriptions for **auto-invocation**. Enhance with examples:
|
||||
|
||||
**Current** (basic):
|
||||
```yaml
|
||||
description: Manages Arch Linux workstation - system maintenance...
|
||||
```
|
||||
|
||||
**Optimized** (with examples):
|
||||
```yaml
|
||||
description: |
|
||||
Manages Arch Linux workstation. Use for system maintenance, updates,
|
||||
troubleshooting, and health checks.
|
||||
Examples:
|
||||
- "check system health" → @linux-sysadmin
|
||||
- "update packages" → @linux-sysadmin
|
||||
- "why is my disk full" → @linux-sysadmin
|
||||
```
|
||||
|
||||
### Permission Migration
|
||||
|
||||
Move guardrail logic to opencode.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"permission": {
|
||||
"edit": "ask",
|
||||
"bash": {
|
||||
"*": "ask",
|
||||
"pacman -Q*": "allow",
|
||||
"systemctl status*": "allow",
|
||||
"kubectl get*": "allow",
|
||||
"git status": "allow",
|
||||
"git diff": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Create OpenCode Config Structure
|
||||
|
||||
### Directory Layout
|
||||
|
||||
```
|
||||
~/.config/opencode/
|
||||
├── opencode.json # Main config
|
||||
├── AGENTS.md # Global rules (symlink or copy from CLAUDE.md)
|
||||
├── agent/ # Agent definitions
|
||||
│ ├── personal-assistant.md
|
||||
│ ├── linux-sysadmin.md
|
||||
│ ├── k8s-orchestrator.md
|
||||
│ └── ... (converted agents)
|
||||
├── tool/ # Custom tool wrappers (TypeScript)
|
||||
│ ├── gmail.ts # Wrapper for gmail scripts
|
||||
│ ├── gcal.ts # Wrapper for gcal scripts
|
||||
│ └── ...
|
||||
└── skill/ # OpenCode-native skills (optional)
|
||||
```
|
||||
|
||||
### Config File: `~/.config/opencode/opencode.json`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
"small_model": "anthropic/claude-haiku-4-5",
|
||||
"autoupdate": true,
|
||||
|
||||
// OpenCode already searches ~/.claude/skills/ - no extra config needed!
|
||||
|
||||
// Agent definitions
|
||||
"agent": {
|
||||
// Override built-in agents or define custom via files
|
||||
},
|
||||
|
||||
// Default permissions (conservative like your current setup)
|
||||
"permission": {
|
||||
"edit": "ask",
|
||||
"bash": "ask"
|
||||
},
|
||||
|
||||
// Custom tools enabled
|
||||
"tools": {
|
||||
"gmail": true,
|
||||
"gcal": true,
|
||||
"gtasks": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Skills Migration
|
||||
|
||||
### Already Compatible (No Changes Needed)
|
||||
|
||||
OpenCode automatically discovers skills from:
|
||||
- `~/.claude/skills/*/SKILL.md`
|
||||
|
||||
Your existing skills should work if they have proper frontmatter:
|
||||
|
||||
| Skill | Status | Notes |
|
||||
|-------|--------|-------|
|
||||
| gmail | Check frontmatter | Needs `name` + `description` |
|
||||
| gcal | Check frontmatter | Needs `name` + `description` |
|
||||
| gtasks | Check frontmatter | Needs `name` + `description` |
|
||||
| sysadmin-health | Check frontmatter | |
|
||||
| k8s-quick-status | Check frontmatter | |
|
||||
| morning-report | Check frontmatter | |
|
||||
| stock-lookup | Check frontmatter | |
|
||||
| rag-search | Check frontmatter | |
|
||||
| usage | Check frontmatter | |
|
||||
| guardrails | N/A | Becomes permission config |
|
||||
|
||||
### Frontmatter Requirements
|
||||
|
||||
Each SKILL.md needs:
|
||||
```yaml
|
||||
---
|
||||
name: skill-name # Required, must match directory name
|
||||
description: Brief desc # Required, 1-1024 chars
|
||||
---
|
||||
```
|
||||
|
||||
### Audit Results (Already Compatible!)
|
||||
|
||||
Checked skills have proper frontmatter:
|
||||
- `gmail/SKILL.md` - Has `name: gmail`, `description: ...`
|
||||
- `sysadmin-health/SKILL.md` - Has `name: sysadmin-health`, `description: ...`
|
||||
- `morning-report/SKILL.md` - Has `name: morning-report`, `description: ...`
|
||||
|
||||
The `allowed-tools` field in some skills will be ignored by OpenCode (not in their schema), but this is fine.
|
||||
|
||||
### Action Items
|
||||
|
||||
1. ~~Audit each SKILL.md for required frontmatter~~ **Done - already compatible!**
|
||||
2. ~~Add missing `name`/`description` fields~~ **Not needed**
|
||||
3. Test skill discovery in OpenCode after install
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Agent Migration
|
||||
|
||||
### Mapping Strategy
|
||||
|
||||
| Claude Code | OpenCode | Notes |
|
||||
|------------|----------|-------|
|
||||
| `model: opus` | `model: anthropic/claude-opus-4` | Full provider/model path |
|
||||
| `model: sonnet` | `model: anthropic/claude-sonnet-4-5` | |
|
||||
| `model: haiku` | `model: anthropic/claude-haiku-4-5` | |
|
||||
| `tools: Read, Write...` | `tools: { write: true, ... }` | Boolean map |
|
||||
| Hierarchy (PA → MO → agent) | `mode: primary` + `mode: subagent` | Flattened |
|
||||
|
||||
### Agent Conversion Template
|
||||
|
||||
**From (Claude Code):**
|
||||
```yaml
|
||||
---
|
||||
name: linux-sysadmin
|
||||
description: Manages Arch Linux workstation...
|
||||
model: sonnet
|
||||
tools: Bash, Read, Write, Edit, Grep, Glob
|
||||
---
|
||||
```
|
||||
|
||||
**To (OpenCode):**
|
||||
```yaml
|
||||
---
|
||||
name: linux-sysadmin
|
||||
description: Manages Arch Linux workstation...
|
||||
mode: subagent
|
||||
model: anthropic/claude-sonnet-4-5
|
||||
tools:
|
||||
bash: true
|
||||
read: true
|
||||
write: true
|
||||
edit: true
|
||||
permission:
|
||||
bash:
|
||||
"*": ask
|
||||
"pacman -Q*": allow
|
||||
"systemctl status*": allow
|
||||
---
|
||||
```
|
||||
|
||||
### Priority Agents to Convert
|
||||
|
||||
1. **personal-assistant.md** → `mode: primary` (main interface)
|
||||
2. **linux-sysadmin.md** → `mode: subagent`
|
||||
3. **k8s-orchestrator.md** → `mode: subagent`
|
||||
4. **master-orchestrator.md** → May not be needed (OpenCode doesn't have same hierarchy)
|
||||
|
||||
### Hierarchy Adaptation
|
||||
|
||||
OpenCode doesn't have hierarchical agent delegation like your current setup. Options:
|
||||
- **Option A**: Flatten to primary + subagents, use `@agent` mentions
|
||||
- **Option B**: Use OpenCode's Task tool for agent invocation
|
||||
- **Option C**: Create a "dispatcher" primary agent that routes via @mentions
|
||||
|
||||
**Recommendation**: Option A (simplest) - personal-assistant as primary, others as subagents invokable via `@linux-sysadmin`, `@k8s-orchestrator`, etc.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Custom Tools (Scripts Execution)
|
||||
|
||||
### Wrapper Pattern
|
||||
|
||||
Create TypeScript wrappers that invoke your existing Python scripts:
|
||||
|
||||
**Example: `~/.config/opencode/tool/gmail.ts`**
|
||||
|
||||
```typescript
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
|
||||
export const check_unread = tool({
|
||||
description: "Check unread emails from Gmail",
|
||||
args: {
|
||||
limit: tool.schema.number().optional().describe("Max emails to return"),
|
||||
},
|
||||
async execute(args) {
|
||||
const limit = args.limit ?? 10
|
||||
const result = await Bun.$`~/.claude/mcp/gmail/venv/bin/python ~/.claude/skills/gmail/scripts/check_unread.py --limit ${limit}`.text()
|
||||
return result.trim()
|
||||
},
|
||||
})
|
||||
|
||||
export const search = tool({
|
||||
description: "Search Gmail for specific emails",
|
||||
args: {
|
||||
query: tool.schema.string().describe("Search query"),
|
||||
},
|
||||
async execute(args) {
|
||||
const result = await Bun.$`~/.claude/mcp/gmail/venv/bin/python ~/.claude/skills/gmail/scripts/search.py "${args.query}"`.text()
|
||||
return result.trim()
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Tools to Create Wrappers For
|
||||
|
||||
| Script | Wrapper |
|
||||
|--------|---------|
|
||||
| `gmail/scripts/*.py` | `gmail.ts` |
|
||||
| `gcal/scripts/*.py` | `gcal.ts` |
|
||||
| `gtasks/scripts/*.py` | `gtasks.ts` |
|
||||
| `sysadmin-health/scripts/*.sh` | `sysadmin.ts` |
|
||||
| `morning-report/scripts/*.py` | `morning.ts` |
|
||||
| `stock-lookup/scripts/*.py` | `stocks.ts` |
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Rules/Instructions
|
||||
|
||||
### Option A: Symlink CLAUDE.md
|
||||
|
||||
```bash
|
||||
ln -s ~/.claude/CLAUDE.md ~/.config/opencode/AGENTS.md
|
||||
```
|
||||
|
||||
### Option B: Create Minimal AGENTS.md + Reference
|
||||
|
||||
```markdown
|
||||
# OpenCode Agent Rules
|
||||
|
||||
Read @~/.claude/CLAUDE.md for shared conventions.
|
||||
|
||||
## OpenCode-Specific
|
||||
|
||||
- Use `@agent-name` to invoke subagents
|
||||
- Skills are loaded via the `skill` tool
|
||||
- Custom tools available: gmail, gcal, gtasks, sysadmin
|
||||
```
|
||||
|
||||
### Option C: Use instructions config
|
||||
|
||||
```json
|
||||
{
|
||||
"instructions": ["~/.claude/CLAUDE.md", "~/.claude/state/system-instructions.json"]
|
||||
}
|
||||
```
|
||||
|
||||
**Recommendation**: Option C - cleanest, no duplication
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: State Persistence (Claude Code as Source of Truth)
|
||||
|
||||
### Strategy
|
||||
|
||||
Claude Code owns the state files. OpenCode reads them via:
|
||||
1. `{file:path}` variable substitution in `opencode.json`
|
||||
2. `instructions` array for context files
|
||||
3. Skills that read state files directly
|
||||
|
||||
### What Can Be Shared
|
||||
|
||||
| File | Method | Notes |
|
||||
|------|--------|-------|
|
||||
| `~/.claude/CLAUDE.md` | `instructions` | Global rules |
|
||||
| `~/.claude/state/kb.json` | `instructions` or skill | Knowledge base |
|
||||
| `~/.claude/state/personal-assistant/memory/*.json` | `instructions` | Memory context |
|
||||
| `~/.claude/state/system-instructions.json` | `instructions` | Process definitions |
|
||||
|
||||
### Implementation in `opencode.json`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
|
||||
// Load Claude Code state as instructions (read at session start)
|
||||
"instructions": [
|
||||
"~/.claude/CLAUDE.md",
|
||||
"~/.claude/state/kb.json",
|
||||
"~/.claude/state/personal-assistant/memory/facts.json",
|
||||
"~/.claude/state/personal-assistant/memory/preferences.json"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### What Stays Separate
|
||||
|
||||
| Item | Reason |
|
||||
|------|--------|
|
||||
| Session history | Different formats, different storage |
|
||||
| Autonomy/permissions | OpenCode uses `permission` config instead |
|
||||
| Component registry | OpenCode discovers via file paths |
|
||||
|
||||
### Overhead Assessment
|
||||
|
||||
**Low overhead** - just config changes:
|
||||
- Add paths to `instructions` array
|
||||
- No symlinks or sync scripts needed
|
||||
- OpenCode reads files directly at session start
|
||||
- Claude Code continues to write/update normally
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: What Won't Transfer
|
||||
|
||||
| Feature | Claude Code | OpenCode Alternative |
|
||||
|---------|-------------|---------------------|
|
||||
| Hooks (SessionStart, etc.) | `hooks/hooks.json` | Plugins (future) |
|
||||
| Guardrails hook | PreToolUse script | `permission` config |
|
||||
| Component registry routing | Keyword triggers | Agent descriptions + @mentions |
|
||||
| Hierarchical delegation | PA → MO → agent | Flat subagent model |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Step 1: Backup (5 min) ✅ COMPLETE
|
||||
- [x] Create timestamped backup of `~/.config/opencode/` → `opencode-backup-20260107_120135.tar.gz`
|
||||
- [x] Create timestamped backup of `~/.opencode/` → `opencode-home-backup-20260107_120136.tar.gz`
|
||||
|
||||
### Step 2: Enhance Sync Script (45 min) ✅ COMPLETE
|
||||
- [x] Add skip list: `SKIP_AGENTS` (kept empty - all agents synced as subagents)
|
||||
- [x] Add `mode: subagent` to all synced agents
|
||||
- [x] Remove hardcoded model (agents inherit from runtime selection)
|
||||
- [x] Add model stripping from opencode.json
|
||||
- [x] Update `sync_tree()` to handle skipped files
|
||||
- [ ] ~~Optionally enhance descriptions with examples~~ (deferred)
|
||||
|
||||
### Step 3: Run Enhanced Sync (10 min) ✅ COMPLETE
|
||||
- [x] `python3 ~/.config/opencode/scripts/claude_sync.py --dry-run`
|
||||
- [x] Review output - verify mode/model changes
|
||||
- [x] `python3 ~/.config/opencode/scripts/claude_sync.py`
|
||||
- [x] All synced: 10 skills, 13 agents, 27 commands, 10 workflows
|
||||
|
||||
### Step 4: Update opencode.json (20 min) ✅ COMPLETE
|
||||
- [x] Add `instructions` array (CLAUDE.md, kb.json, memory files)
|
||||
- [x] Model defaults: intentionally omitted (user selects at runtime)
|
||||
- [x] Add permission config with safe command patterns
|
||||
|
||||
### Step 5: Testing (30 min) ✅ COMPLETE (automated)
|
||||
- [x] OpenCode v1.0.220 installed at `/home/linuxbrew/.linuxbrew/bin/opencode`
|
||||
- [x] `opencode agent list` shows 40 agents (built-in + synced)
|
||||
- [x] All Claude Code agents show as `(subagent)`
|
||||
- [x] 10 skills synced to `~/.config/opencode/skills/`
|
||||
- [x] Config verified: instructions, permissions, commands present
|
||||
- [ ] Manual TUI testing (user to verify interactively)
|
||||
|
||||
### Step 6: Documentation (20 min) ✅ COMPLETE
|
||||
- [x] Create `~/.config/opencode/README.md` (4.7KB)
|
||||
- [x] Document complete agent mapping table
|
||||
- [x] Document sync workflow with examples
|
||||
- [x] Add fc-047 to `~/.claude/state/future-considerations.json`
|
||||
|
||||
### Step 7: Iterate (as needed) ⏳ PENDING
|
||||
- [ ] Adjust agent descriptions if auto-invocation isn't working well
|
||||
- [ ] Tune permission patterns
|
||||
- [ ] Consider dropping/hiding agents that don't fit OpenCode model
|
||||
- [ ] Update documentation with lessons learned
|
||||
|
||||
**Status: IMPLEMENTATION COMPLETE** - Manual TUI testing recommended
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Documentation
|
||||
|
||||
### Documentation Deliverables
|
||||
|
||||
Create `~/.config/opencode/README.md` with:
|
||||
|
||||
1. **Architecture Overview**
|
||||
- Relationship between Claude Code and OpenCode
|
||||
- What's shared vs separate
|
||||
- Source of truth (Claude Code)
|
||||
|
||||
2. **Sync Workflow**
|
||||
- How `claude_sync.py` works
|
||||
- When to run it (after Claude Code changes)
|
||||
- Command reference
|
||||
|
||||
3. **Agent Mapping**
|
||||
- Which Claude Code agents map to OpenCode
|
||||
- Which are skipped and why
|
||||
- How to invoke subagents (@mentions)
|
||||
|
||||
4. **Skills**
|
||||
- Auto-discovery from `~/.claude/skills/`
|
||||
- How to add new skills
|
||||
- Skill invocation patterns
|
||||
|
||||
5. **State Sharing**
|
||||
- Files referenced via `instructions`
|
||||
- Claude Code as source of truth
|
||||
- What stays separate
|
||||
|
||||
6. **Permissions**
|
||||
- How guardrails translated to `permission` config
|
||||
- Safe vs prompted commands
|
||||
|
||||
### Documentation Template
|
||||
|
||||
```markdown
|
||||
# OpenCode Configuration
|
||||
|
||||
This OpenCode setup is synchronized from Claude Code (`~/.claude/`).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Start OpenCode (uses built-in build agent)
|
||||
opencode
|
||||
|
||||
# Switch to read-only plan agent
|
||||
# Press Tab
|
||||
|
||||
# Invoke a subagent
|
||||
@linux-sysadmin check system health
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Claude Code (source of truth)
|
||||
├── ~/.claude/agents/ → synced to ~/.config/opencode/agents/
|
||||
├── ~/.claude/skills/ → synced to ~/.config/opencode/skills/
|
||||
├── ~/.claude/CLAUDE.md → referenced via instructions
|
||||
└── ~/.claude/state/ → referenced via instructions
|
||||
|
||||
OpenCode
|
||||
├── Built-in: build (primary), plan (read-only)
|
||||
├── Subagents: @linux-sysadmin, @k8s-orchestrator, etc.
|
||||
└── Skills: gmail, gcal, sysadmin-health, etc.
|
||||
```
|
||||
|
||||
## Sync Workflow
|
||||
|
||||
After making changes in Claude Code:
|
||||
|
||||
```bash
|
||||
# Preview changes
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py --dry-run
|
||||
|
||||
# Apply changes
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py
|
||||
|
||||
# Clean stale files
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py --clean --apply
|
||||
```
|
||||
|
||||
## Agents
|
||||
|
||||
| Claude Code | OpenCode | Notes |
|
||||
|-------------|----------|-------|
|
||||
| personal-assistant | (skipped) | Use built-in `build` |
|
||||
| master-orchestrator | (skipped) | Flat model, not needed |
|
||||
| linux-sysadmin | @linux-sysadmin | Subagent |
|
||||
| k8s-orchestrator | @k8s-orchestrator | Subagent |
|
||||
| ... | ... | ... |
|
||||
|
||||
## Skills
|
||||
|
||||
Skills are auto-discovered from:
|
||||
- `~/.claude/skills/*/SKILL.md`
|
||||
- `~/.config/opencode/skills/*/SKILL.md`
|
||||
|
||||
## State Files
|
||||
|
||||
Referenced via `instructions` in opencode.json:
|
||||
- `~/.claude/CLAUDE.md` - Global rules
|
||||
- `~/.claude/state/kb.json` - Knowledge base
|
||||
- `~/.claude/state/personal-assistant/memory/*.json` - Memory
|
||||
|
||||
## Permissions
|
||||
|
||||
Configured in opencode.json `permission` section.
|
||||
Migrated from Claude Code's guardrail hooks.
|
||||
```
|
||||
|
||||
### Implementation Step
|
||||
|
||||
Add to Step 6:
|
||||
- [ ] Create `~/.config/opencode/README.md`
|
||||
- [ ] Document sync workflow
|
||||
- [ ] Document agent mapping
|
||||
- [ ] Document any gotchas discovered during testing
|
||||
|
||||
---
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
### Files to Create
|
||||
- `~/.config/opencode/README.md` - Documentation of setup, workflow, and requirements
|
||||
|
||||
### Files to Modify
|
||||
- `~/.config/opencode/opencode.json` - Add `instructions` array + model/permission config
|
||||
- `~/.config/opencode/scripts/claude_sync.py` - Add mode, model mappings, skip list
|
||||
|
||||
### Files Auto-Synced by Script
|
||||
These are created/updated by `claude_sync.py`:
|
||||
- `~/.config/opencode/agents/*.md` - From `~/.claude/agents/`
|
||||
- `~/.config/opencode/skills/*/SKILL.md` - From `~/.claude/skills/`
|
||||
- `~/.config/opencode/claude/commands/*.md` - From `~/.claude/commands/`
|
||||
- `~/.config/opencode/claude/workflows/*.yaml` - From `~/.claude/workflows/`
|
||||
|
||||
### Files Referenced (Not Copied)
|
||||
These stay in Claude Code, referenced via `instructions`:
|
||||
- `~/.claude/CLAUDE.md`
|
||||
- `~/.claude/state/kb.json`
|
||||
- `~/.claude/state/personal-assistant/memory/*.json`
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. `opencode` launches and shows available skills
|
||||
2. Can invoke `@linux-sysadmin` and get expected behavior
|
||||
3. Gmail/GCal/GTasks tools work via custom wrappers
|
||||
4. Can switch between build/plan agents + custom agents
|
||||
5. Both Claude Code and OpenCode can run in parallel without conflicts
|
||||
@@ -0,0 +1,150 @@
|
||||
# Plan: Session Summarization Hook
|
||||
|
||||
## Problem
|
||||
|
||||
Sessions are tracked in `~/.claude/state/personal-assistant/history/index.json` but:
|
||||
1. No conversation logs are captured to our history folder
|
||||
2. Sessions never get marked as summarized
|
||||
3. Memory files remain empty (decisions, preferences, projects, facts)
|
||||
|
||||
## Root Cause
|
||||
|
||||
Missing `SessionEnd` hook to trigger summarization when sessions end.
|
||||
|
||||
## Solution
|
||||
|
||||
Add a `SessionEnd` hook that:
|
||||
1. Reads the transcript from Claude's built-in storage (`transcript_path`)
|
||||
2. Extracts key information (decisions, preferences, project context, facts)
|
||||
3. Saves to appropriate memory files
|
||||
4. Updates history index to mark session as summarized
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `~/.claude/hooks/hooks.json` | Add `SessionEnd` hook entry |
|
||||
| `~/.claude/hooks/scripts/session-end.sh` | **Create** - orchestrates summarization |
|
||||
| `~/.claude/hooks/scripts/summarize-transcript.py` | **Create** - Python script to process transcript |
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Hook Configuration (`hooks.json`)
|
||||
|
||||
Add `SessionEnd` hook that calls the summarization script:
|
||||
|
||||
```json
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.claude/hooks/scripts/session-end.sh",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 2. Session End Script (`session-end.sh`)
|
||||
|
||||
- Receives JSON via stdin with `session_id`, `transcript_path`, `reason`
|
||||
- Calls Python summarization script
|
||||
- Handles errors gracefully (session end shouldn't fail)
|
||||
|
||||
### 3. Summarization Script (`summarize-transcript.py`)
|
||||
|
||||
The script will:
|
||||
|
||||
1. **Parse transcript** - Read the `.jsonl` file from `transcript_path`
|
||||
2. **Extract key items** - Use heuristics to identify:
|
||||
- Decisions: "let's use", "we decided", "I'll go with"
|
||||
- Preferences: "I prefer", "always", "never", "I like"
|
||||
- Project context: file paths, config references
|
||||
- Facts: environment info, tool locations
|
||||
3. **Deduplicate** - Check against existing memory items
|
||||
4. **Save to memory files** - Append new items with UUIDs
|
||||
5. **Update history index** - Mark session as summarized, add topics
|
||||
|
||||
### Processing Approach: Hybrid (Decision)
|
||||
|
||||
**Step 1: Threshold check**
|
||||
- Skip sessions with < 3 user messages
|
||||
- Skip sessions that are only quick commands (no substantive discussion)
|
||||
|
||||
**Step 2: Heuristic extraction (fast, no API)**
|
||||
- File paths mentioned → project context
|
||||
- Environment facts (tool locations, versions)
|
||||
- Simple preferences with clear keywords
|
||||
|
||||
**Step 3: LLM extraction (if substantive content)**
|
||||
- Complex decisions with rationale
|
||||
- Nuanced preferences
|
||||
- Project context requiring interpretation
|
||||
- Use Claude API (Haiku for cost efficiency)
|
||||
|
||||
### Transcript Storage (Decision)
|
||||
|
||||
Reference Claude's existing transcript location (`~/.claude/projects/.../[uuid].jsonl`) rather than copying to our history folder. The history index will store the transcript path for future reference.
|
||||
|
||||
### Memory File Format
|
||||
|
||||
Each item:
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"date": "YYYY-MM-DD",
|
||||
"content": "Brief description",
|
||||
"context": "Additional context",
|
||||
"session": "session-id"
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Update hooks.json
|
||||
|
||||
Add `SessionEnd` entry to `~/.claude/hooks/hooks.json`
|
||||
|
||||
### Step 2: Create session-end.sh
|
||||
|
||||
Shell wrapper at `~/.claude/hooks/scripts/session-end.sh`:
|
||||
- Parse JSON input from stdin
|
||||
- Extract session_id, transcript_path, reason
|
||||
- Call Python summarization script
|
||||
- Handle errors silently (don't break session exit)
|
||||
|
||||
### Step 3: Create summarize-transcript.py
|
||||
|
||||
Python script at `~/.claude/hooks/scripts/summarize-transcript.py`:
|
||||
|
||||
```
|
||||
Arguments: --session-id <id> --transcript <path> [--reason <reason>]
|
||||
|
||||
1. Load transcript (.jsonl)
|
||||
2. Count user messages → skip if < 3
|
||||
3. Heuristic pass:
|
||||
- Extract file paths → projects.json
|
||||
- Extract env facts → facts.json
|
||||
4. If substantive content detected:
|
||||
- Call Claude API (Haiku) for decisions/preferences
|
||||
- Parse response → decisions.json, preferences.json
|
||||
5. Update history/index.json:
|
||||
- Set summarized: true
|
||||
- Add transcript_path
|
||||
- Add extracted topics
|
||||
```
|
||||
|
||||
### Step 4: Update history index schema
|
||||
|
||||
Add `transcript_path` field to session entries in `history/index.json`
|
||||
|
||||
## Testing
|
||||
|
||||
1. Start a test session with substantive discussion
|
||||
2. Exit session normally
|
||||
3. Verify:
|
||||
- Hook fired (check with `--debug`)
|
||||
- Memory files updated
|
||||
- History index marked summarized
|
||||
@@ -0,0 +1,903 @@
|
||||
# External LLM Integration Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Enable agents to use external LLMs (Copilot, Z.AI, Gemini) via CLI tools with a session toggle.
|
||||
|
||||
**Architecture:** Python router reads model-policy.json, invokes appropriate CLI (opencode/gemini), returns response. State file controls Claude vs external mode. PA exposes toggle commands.
|
||||
|
||||
**Tech Stack:** Python 3, subprocess, JSON state files, opencode CLI, gemini CLI
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create External Mode State File
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/state/external-mode.json`
|
||||
|
||||
**Step 1: Create state file**
|
||||
|
||||
```bash
|
||||
cat > ~/.claude/state/external-mode.json << 'EOF'
|
||||
{
|
||||
"enabled": false,
|
||||
"activated_at": null,
|
||||
"reason": null
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
**Step 2: Verify file**
|
||||
|
||||
Run: `cat ~/.claude/state/external-mode.json | jq .`
|
||||
Expected: Valid JSON with `enabled: false`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude add state/external-mode.json
|
||||
git -C ~/.claude commit -m "feat(external-llm): add external-mode state file"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Extend Model Policy
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/.claude/state/model-policy.json`
|
||||
|
||||
**Step 1: Read current file**
|
||||
|
||||
Run: `cat ~/.claude/state/model-policy.json | jq .`
|
||||
|
||||
**Step 2: Add external_models section**
|
||||
|
||||
Add to model-policy.json (after `skill_delegation` section):
|
||||
|
||||
```json
|
||||
"external_models": {
|
||||
"copilot/gpt-5.2": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["--provider", "copilot", "--model", "gpt-5.2"],
|
||||
"use_cases": ["reasoning", "fallback"],
|
||||
"tier": "opus-equivalent"
|
||||
},
|
||||
"copilot/sonnet-4.5": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["--provider", "copilot", "--model", "sonnet-4.5"],
|
||||
"use_cases": ["general", "fallback"],
|
||||
"tier": "sonnet-equivalent"
|
||||
},
|
||||
"copilot/haiku-4.5": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["--provider", "copilot", "--model", "haiku-4.5"],
|
||||
"use_cases": ["simple"],
|
||||
"tier": "haiku-equivalent"
|
||||
},
|
||||
"zai/glm-4.7": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["--provider", "zai", "--model", "glm-4.7"],
|
||||
"use_cases": ["code-generation"],
|
||||
"tier": "sonnet-equivalent"
|
||||
},
|
||||
"gemini/gemini-3-pro": {
|
||||
"cli": "gemini",
|
||||
"cli_args": ["-m", "gemini-3-pro"],
|
||||
"use_cases": ["long-context"],
|
||||
"tier": "opus-equivalent"
|
||||
}
|
||||
},
|
||||
"claude_to_external_map": {
|
||||
"opus": "copilot/gpt-5.2",
|
||||
"sonnet": "copilot/sonnet-4.5",
|
||||
"haiku": "copilot/haiku-4.5"
|
||||
},
|
||||
"task_routing": {
|
||||
"reasoning": "copilot/gpt-5.2",
|
||||
"code-generation": "zai/glm-4.7",
|
||||
"long-context": "gemini/gemini-3-pro",
|
||||
"default": "copilot/sonnet-4.5"
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Validate JSON**
|
||||
|
||||
Run: `cat ~/.claude/state/model-policy.json | jq .`
|
||||
Expected: Valid JSON, no errors
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude add state/model-policy.json
|
||||
git -C ~/.claude commit -m "feat(external-llm): add external model definitions to policy"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Create Router Directory Structure
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/mcp/llm-router/`
|
||||
- Create: `~/.claude/mcp/llm-router/providers/`
|
||||
- Create: `~/.claude/mcp/llm-router/providers/__init__.py`
|
||||
|
||||
**Step 1: Create directories**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.claude/mcp/llm-router/providers
|
||||
```
|
||||
|
||||
**Step 2: Create __init__.py**
|
||||
|
||||
```bash
|
||||
touch ~/.claude/mcp/llm-router/providers/__init__.py
|
||||
```
|
||||
|
||||
**Step 3: Verify structure**
|
||||
|
||||
Run: `ls -la ~/.claude/mcp/llm-router/`
|
||||
Expected: `providers/` directory exists
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude add mcp/llm-router/
|
||||
git -C ~/.claude commit -m "feat(external-llm): create llm-router directory structure"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Create OpenCode Provider
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/mcp/llm-router/providers/opencode.py`
|
||||
|
||||
**Step 1: Write provider**
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""OpenCode CLI wrapper for Copilot, Z.AI, and other providers."""
|
||||
|
||||
import subprocess
|
||||
from typing import List
|
||||
|
||||
|
||||
def invoke(cli_args: List[str], prompt: str, timeout: int = 300) -> str:
|
||||
"""
|
||||
Invoke opencode CLI with given args and prompt.
|
||||
|
||||
Args:
|
||||
cli_args: Provider/model args like ["--provider", "copilot", "--model", "gpt-5.2"]
|
||||
prompt: The prompt text
|
||||
timeout: Timeout in seconds (default 5 minutes)
|
||||
|
||||
Returns:
|
||||
Model response as string
|
||||
|
||||
Raises:
|
||||
RuntimeError: If opencode CLI fails
|
||||
TimeoutError: If request exceeds timeout
|
||||
"""
|
||||
cmd = ["opencode", "--print"] + cli_args + ["-p", prompt]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise TimeoutError(f"opencode timed out after {timeout}s")
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"opencode failed (exit {result.returncode}): {result.stderr}")
|
||||
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Quick test
|
||||
import sys
|
||||
if len(sys.argv) > 1:
|
||||
response = invoke(["--provider", "copilot", "--model", "gpt-5.2"], sys.argv[1])
|
||||
print(response)
|
||||
else:
|
||||
print("Usage: opencode.py 'prompt'")
|
||||
```
|
||||
|
||||
**Step 2: Make executable**
|
||||
|
||||
```bash
|
||||
chmod +x ~/.claude/mcp/llm-router/providers/opencode.py
|
||||
```
|
||||
|
||||
**Step 3: Verify syntax**
|
||||
|
||||
Run: `python3 -m py_compile ~/.claude/mcp/llm-router/providers/opencode.py`
|
||||
Expected: No output (success)
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude add mcp/llm-router/providers/opencode.py
|
||||
git -C ~/.claude commit -m "feat(external-llm): add opencode provider wrapper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Create Gemini Provider
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/mcp/llm-router/providers/gemini.py`
|
||||
|
||||
**Step 1: Write provider**
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""Gemini CLI wrapper for Google models."""
|
||||
|
||||
import subprocess
|
||||
from typing import List
|
||||
|
||||
|
||||
def invoke(cli_args: List[str], prompt: str, timeout: int = 300) -> str:
|
||||
"""
|
||||
Invoke gemini CLI with given args and prompt.
|
||||
|
||||
Args:
|
||||
cli_args: Model args like ["-m", "gemini-3-pro"]
|
||||
prompt: The prompt text
|
||||
timeout: Timeout in seconds (default 5 minutes)
|
||||
|
||||
Returns:
|
||||
Model response as string
|
||||
|
||||
Raises:
|
||||
RuntimeError: If gemini CLI fails
|
||||
TimeoutError: If request exceeds timeout
|
||||
"""
|
||||
cmd = ["gemini"] + cli_args + ["-p", prompt]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise TimeoutError(f"gemini timed out after {timeout}s")
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"gemini failed (exit {result.returncode}): {result.stderr}")
|
||||
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Quick test
|
||||
import sys
|
||||
if len(sys.argv) > 1:
|
||||
response = invoke(["-m", "gemini-3-pro"], sys.argv[1])
|
||||
print(response)
|
||||
else:
|
||||
print("Usage: gemini.py 'prompt'")
|
||||
```
|
||||
|
||||
**Step 2: Make executable**
|
||||
|
||||
```bash
|
||||
chmod +x ~/.claude/mcp/llm-router/providers/gemini.py
|
||||
```
|
||||
|
||||
**Step 3: Verify syntax**
|
||||
|
||||
Run: `python3 -m py_compile ~/.claude/mcp/llm-router/providers/gemini.py`
|
||||
Expected: No output (success)
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude add mcp/llm-router/providers/gemini.py
|
||||
git -C ~/.claude commit -m "feat(external-llm): add gemini provider wrapper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Create Main Router (invoke.py)
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/mcp/llm-router/invoke.py`
|
||||
|
||||
**Step 1: Write router**
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Invoke external LLM via configured provider.
|
||||
|
||||
Usage:
|
||||
invoke.py --model copilot/gpt-5.2 -p "prompt"
|
||||
invoke.py --task reasoning -p "prompt"
|
||||
invoke.py --task code-generation -p "prompt" --json
|
||||
|
||||
Model selection priority:
|
||||
1. Explicit --model flag
|
||||
2. Task-based routing (--task flag)
|
||||
3. Default from policy
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
STATE_DIR = Path.home() / ".claude/state"
|
||||
ROUTER_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def load_policy() -> dict:
|
||||
"""Load model policy from state file."""
|
||||
policy_file = STATE_DIR / "model-policy.json"
|
||||
with open(policy_file) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def resolve_model(args: argparse.Namespace, policy: dict) -> str:
|
||||
"""Determine which model to use based on args and policy."""
|
||||
if args.model:
|
||||
return args.model
|
||||
if args.task and args.task in policy.get("task_routing", {}):
|
||||
return policy["task_routing"][args.task]
|
||||
return policy.get("task_routing", {}).get("default", "copilot/sonnet-4.5")
|
||||
|
||||
|
||||
def invoke(model: str, prompt: str, policy: dict) -> str:
|
||||
"""Invoke the appropriate provider for the given model."""
|
||||
external_models = policy.get("external_models", {})
|
||||
|
||||
if model not in external_models:
|
||||
raise ValueError(f"Unknown model: {model}. Available: {list(external_models.keys())}")
|
||||
|
||||
model_config = external_models[model]
|
||||
cli = model_config["cli"]
|
||||
cli_args = model_config.get("cli_args", [])
|
||||
|
||||
# Import and invoke appropriate provider
|
||||
if cli == "opencode":
|
||||
sys.path.insert(0, str(ROUTER_DIR))
|
||||
from providers.opencode import invoke as opencode_invoke
|
||||
return opencode_invoke(cli_args, prompt)
|
||||
elif cli == "gemini":
|
||||
sys.path.insert(0, str(ROUTER_DIR))
|
||||
from providers.gemini import invoke as gemini_invoke
|
||||
return gemini_invoke(cli_args, prompt)
|
||||
else:
|
||||
raise ValueError(f"Unknown CLI: {cli}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Invoke external LLM via configured provider"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p", "--prompt",
|
||||
required=True,
|
||||
help="Prompt text"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
help="Explicit model (e.g., copilot/gpt-5.2)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--task",
|
||||
choices=["reasoning", "code-generation", "long-context", "general"],
|
||||
help="Task type for automatic model routing"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON with model info"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=int,
|
||||
default=300,
|
||||
help="Timeout in seconds (default: 300)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
policy = load_policy()
|
||||
model = resolve_model(args, policy)
|
||||
result = invoke(model, args.prompt, policy)
|
||||
|
||||
if args.json:
|
||||
output = {
|
||||
"model": model,
|
||||
"response": result,
|
||||
"success": True
|
||||
}
|
||||
print(json.dumps(output, indent=2))
|
||||
else:
|
||||
print(result)
|
||||
|
||||
except Exception as e:
|
||||
if args.json:
|
||||
output = {
|
||||
"model": args.model or "unknown",
|
||||
"error": str(e),
|
||||
"success": False
|
||||
}
|
||||
print(json.dumps(output, indent=2))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
**Step 2: Make executable**
|
||||
|
||||
```bash
|
||||
chmod +x ~/.claude/mcp/llm-router/invoke.py
|
||||
```
|
||||
|
||||
**Step 3: Verify syntax**
|
||||
|
||||
Run: `python3 -m py_compile ~/.claude/mcp/llm-router/invoke.py`
|
||||
Expected: No output (success)
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude add mcp/llm-router/invoke.py
|
||||
git -C ~/.claude commit -m "feat(external-llm): add main router invoke.py"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Create Delegation Helper
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/mcp/llm-router/delegate.py`
|
||||
|
||||
**Step 1: Write delegation helper**
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Agent delegation helper. Routes to external or Claude based on mode.
|
||||
|
||||
Usage:
|
||||
delegate.py --tier sonnet -p "prompt"
|
||||
delegate.py --tier opus -p "complex reasoning task" --json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
STATE_DIR = Path.home() / ".claude/state"
|
||||
ROUTER_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def is_external_mode() -> bool:
|
||||
"""Check if external-only mode is enabled."""
|
||||
mode_file = STATE_DIR / "external-mode.json"
|
||||
if mode_file.exists():
|
||||
with open(mode_file) as f:
|
||||
data = json.load(f)
|
||||
return data.get("enabled", False)
|
||||
return False
|
||||
|
||||
|
||||
def get_external_model(tier: str) -> str:
|
||||
"""Get the external model equivalent for a Claude tier."""
|
||||
policy_file = STATE_DIR / "model-policy.json"
|
||||
with open(policy_file) as f:
|
||||
policy = json.load(f)
|
||||
mapping = policy.get("claude_to_external_map", {})
|
||||
if tier not in mapping:
|
||||
raise ValueError(f"No external mapping for tier: {tier}")
|
||||
return mapping[tier]
|
||||
|
||||
|
||||
def delegate(tier: str, prompt: str, use_json: bool = False) -> str:
|
||||
"""
|
||||
Delegate to appropriate model based on mode.
|
||||
|
||||
Args:
|
||||
tier: Claude tier (opus, sonnet, haiku)
|
||||
prompt: The prompt text
|
||||
use_json: Return JSON output
|
||||
|
||||
Returns:
|
||||
Model response as string
|
||||
"""
|
||||
if is_external_mode():
|
||||
# Use external model
|
||||
model = get_external_model(tier)
|
||||
invoke_script = ROUTER_DIR / "invoke.py"
|
||||
|
||||
cmd = [sys.executable, str(invoke_script), "--model", model, "-p", prompt]
|
||||
if use_json:
|
||||
cmd.append("--json")
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"External invoke failed: {result.stderr}")
|
||||
|
||||
return result.stdout.strip()
|
||||
else:
|
||||
# Use Claude
|
||||
cmd = ["claude", "--print", "--model", tier, prompt]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Claude failed: {result.stderr}")
|
||||
|
||||
response = result.stdout.strip()
|
||||
|
||||
if use_json:
|
||||
return json.dumps({
|
||||
"model": f"claude/{tier}",
|
||||
"response": response,
|
||||
"success": True
|
||||
}, indent=2)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Delegate to Claude or external model based on mode"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tier",
|
||||
required=True,
|
||||
choices=["opus", "sonnet", "haiku"],
|
||||
help="Claude tier (maps to external equivalent when in external mode)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p", "--prompt",
|
||||
required=True,
|
||||
help="Prompt text"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
result = delegate(args.tier, args.prompt, args.json)
|
||||
print(result)
|
||||
except Exception as e:
|
||||
if args.json:
|
||||
print(json.dumps({"error": str(e), "success": False}, indent=2))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
**Step 2: Make executable**
|
||||
|
||||
```bash
|
||||
chmod +x ~/.claude/mcp/llm-router/delegate.py
|
||||
```
|
||||
|
||||
**Step 3: Verify syntax**
|
||||
|
||||
Run: `python3 -m py_compile ~/.claude/mcp/llm-router/delegate.py`
|
||||
Expected: No output (success)
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude add mcp/llm-router/delegate.py
|
||||
git -C ~/.claude commit -m "feat(external-llm): add delegation helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Create Toggle Script
|
||||
|
||||
**Files:**
|
||||
- Create: `~/.claude/mcp/llm-router/toggle.py`
|
||||
|
||||
**Step 1: Write toggle script**
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Toggle external-only mode.
|
||||
|
||||
Usage:
|
||||
toggle.py on [--reason "user requested"]
|
||||
toggle.py off
|
||||
toggle.py status
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
STATE_FILE = Path.home() / ".claude/state/external-mode.json"
|
||||
|
||||
|
||||
def load_state() -> dict:
|
||||
"""Load current state."""
|
||||
if STATE_FILE.exists():
|
||||
with open(STATE_FILE) as f:
|
||||
return json.load(f)
|
||||
return {"enabled": False, "activated_at": None, "reason": None}
|
||||
|
||||
|
||||
def save_state(state: dict):
|
||||
"""Save state to file."""
|
||||
with open(STATE_FILE, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
|
||||
def enable(reason: str = None):
|
||||
"""Enable external-only mode."""
|
||||
state = {
|
||||
"enabled": True,
|
||||
"activated_at": datetime.now().isoformat(),
|
||||
"reason": reason or "user-requested"
|
||||
}
|
||||
save_state(state)
|
||||
print("External-only mode ENABLED")
|
||||
print(f" Activated: {state['activated_at']}")
|
||||
print(f" Reason: {state['reason']}")
|
||||
print("\nAll agent requests will now use external LLMs.")
|
||||
print("Run 'toggle.py off' or '/pa --external off' to disable.")
|
||||
|
||||
|
||||
def disable():
|
||||
"""Disable external-only mode."""
|
||||
state = {
|
||||
"enabled": False,
|
||||
"activated_at": None,
|
||||
"reason": None
|
||||
}
|
||||
save_state(state)
|
||||
print("External-only mode DISABLED")
|
||||
print("\nAll agent requests will now use Claude.")
|
||||
|
||||
|
||||
def status():
|
||||
"""Show current mode status."""
|
||||
state = load_state()
|
||||
if state.get("enabled"):
|
||||
print("External-only mode: ENABLED")
|
||||
print(f" Activated: {state.get('activated_at', 'unknown')}")
|
||||
print(f" Reason: {state.get('reason', 'unknown')}")
|
||||
else:
|
||||
print("External-only mode: DISABLED")
|
||||
print(" Using Claude for all requests.")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Toggle external-only mode")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# on command
|
||||
on_parser = subparsers.add_parser("on", help="Enable external-only mode")
|
||||
on_parser.add_argument("--reason", help="Reason for enabling")
|
||||
|
||||
# off command
|
||||
subparsers.add_parser("off", help="Disable external-only mode")
|
||||
|
||||
# status command
|
||||
subparsers.add_parser("status", help="Show current mode")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "on":
|
||||
enable(args.reason)
|
||||
elif args.command == "off":
|
||||
disable()
|
||||
elif args.command == "status":
|
||||
status()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
**Step 2: Make executable**
|
||||
|
||||
```bash
|
||||
chmod +x ~/.claude/mcp/llm-router/toggle.py
|
||||
```
|
||||
|
||||
**Step 3: Test toggle**
|
||||
|
||||
Run: `~/.claude/mcp/llm-router/toggle.py status`
|
||||
Expected: Shows "External-only mode: DISABLED"
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude add mcp/llm-router/toggle.py
|
||||
git -C ~/.claude commit -m "feat(external-llm): add toggle script"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Update Session Start Hook
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/.claude/hooks/scripts/session-start.sh`
|
||||
|
||||
**Step 1: Read current hook**
|
||||
|
||||
Run: `cat ~/.claude/hooks/scripts/session-start.sh`
|
||||
|
||||
**Step 2: Add external mode check**
|
||||
|
||||
Add before the final output section:
|
||||
|
||||
```bash
|
||||
# Check external mode
|
||||
if [ -f ~/.claude/state/external-mode.json ]; then
|
||||
EXTERNAL_ENABLED=$(jq -r '.enabled // false' ~/.claude/state/external-mode.json)
|
||||
if [ "$EXTERNAL_ENABLED" = "true" ]; then
|
||||
echo "external-mode:enabled"
|
||||
fi
|
||||
fi
|
||||
```
|
||||
|
||||
**Step 3: Verify hook**
|
||||
|
||||
Run: `bash -n ~/.claude/hooks/scripts/session-start.sh`
|
||||
Expected: No output (success)
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude add hooks/scripts/session-start.sh
|
||||
git -C ~/.claude commit -m "feat(external-llm): announce external mode in session-start"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Add Component Registry Triggers
|
||||
|
||||
**Files:**
|
||||
- Modify: `~/.claude/state/component-registry.json`
|
||||
|
||||
**Step 1: Read current registry**
|
||||
|
||||
Run: `cat ~/.claude/state/component-registry.json | jq '.skills'`
|
||||
|
||||
**Step 2: Add external-mode skill entry**
|
||||
|
||||
Add to skills array:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "external-mode-toggle",
|
||||
"name": "External Mode Toggle",
|
||||
"description": "Toggle between Claude and external LLMs",
|
||||
"path": "mcp/llm-router/toggle.py",
|
||||
"triggers": [
|
||||
"use external",
|
||||
"switch to external",
|
||||
"external models",
|
||||
"external only",
|
||||
"use copilot",
|
||||
"use opencode",
|
||||
"back to claude",
|
||||
"use claude again",
|
||||
"disable external",
|
||||
"external mode"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Validate JSON**
|
||||
|
||||
Run: `cat ~/.claude/state/component-registry.json | jq .`
|
||||
Expected: Valid JSON
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude add state/component-registry.json
|
||||
git -C ~/.claude commit -m "feat(external-llm): add external-mode triggers to registry"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Integration Test
|
||||
|
||||
**Step 1: Test toggle**
|
||||
|
||||
```bash
|
||||
~/.claude/mcp/llm-router/toggle.py status
|
||||
~/.claude/mcp/llm-router/toggle.py on --reason "testing"
|
||||
~/.claude/mcp/llm-router/toggle.py status
|
||||
~/.claude/mcp/llm-router/toggle.py off
|
||||
```
|
||||
|
||||
Expected: Status changes correctly
|
||||
|
||||
**Step 2: Test router (mock - will fail without actual CLIs)**
|
||||
|
||||
```bash
|
||||
~/.claude/mcp/llm-router/invoke.py --model copilot/gpt-5.2 -p "Say hello" --json 2>&1 || echo "Expected: CLI not found (normal if opencode not installed)"
|
||||
```
|
||||
|
||||
**Step 3: Test delegation helper status check**
|
||||
|
||||
```bash
|
||||
python3 -c "
|
||||
import sys
|
||||
sys.path.insert(0, '$HOME/.claude/mcp/llm-router')
|
||||
from delegate import is_external_mode
|
||||
print(f'External mode: {is_external_mode()}')
|
||||
"
|
||||
```
|
||||
|
||||
Expected: "External mode: False"
|
||||
|
||||
**Step 4: Final commit**
|
||||
|
||||
```bash
|
||||
git -C ~/.claude commit --allow-empty -m "feat(external-llm): integration complete - gleaming-routing-mercury"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
After completing all tasks:
|
||||
|
||||
| Component | Status |
|
||||
|-----------|--------|
|
||||
| `state/external-mode.json` | Created |
|
||||
| `state/model-policy.json` | Extended |
|
||||
| `mcp/llm-router/invoke.py` | Created |
|
||||
| `mcp/llm-router/delegate.py` | Created |
|
||||
| `mcp/llm-router/toggle.py` | Created |
|
||||
| `mcp/llm-router/providers/opencode.py` | Created |
|
||||
| `mcp/llm-router/providers/gemini.py` | Created |
|
||||
| `hooks/scripts/session-start.sh` | Updated |
|
||||
| `state/component-registry.json` | Updated |
|
||||
|
||||
**To use:**
|
||||
```bash
|
||||
# Enable external mode
|
||||
~/.claude/mcp/llm-router/toggle.py on
|
||||
|
||||
# Or via PA
|
||||
/pa --external on
|
||||
/pa switch to external models
|
||||
|
||||
# Invoke directly
|
||||
~/.claude/mcp/llm-router/invoke.py --task reasoning -p "Explain quantum computing"
|
||||
|
||||
# Delegate (respects mode)
|
||||
~/.claude/mcp/llm-router/delegate.py --tier sonnet -p "Check disk space"
|
||||
```
|
||||
@@ -0,0 +1,375 @@
|
||||
# Plan: External LLM Integration
|
||||
|
||||
## Summary
|
||||
|
||||
Integrate external LLMs (via subscription-based access) into the agent system for cost optimization, specialized capabilities, and redundancy. Uses `opencode` CLI for Copilot/Z.AI models and `gemini` CLI for Google models.
|
||||
|
||||
## Motivation
|
||||
|
||||
- **Cost optimization** — Use cheaper models for simple tasks
|
||||
- **Specialized capabilities** — Access models with unique strengths (GPT-5.2 for reasoning, GLM 4.7 for code)
|
||||
- **Redundancy** — Fallback when Claude is unavailable
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | Choice |
|
||||
|----------|--------|
|
||||
| Provider type | Cloud APIs via subscription (not local) |
|
||||
| Providers | GitHub Copilot, Z.AI, Google Gemini |
|
||||
| CLIs | `opencode` (Copilot, Z.AI), `gemini` (Google) |
|
||||
| Integration | Task-specific routing + agent-level assignment |
|
||||
| Toggle | State file (persists across sessions) |
|
||||
| Toggle scope | All agents switch when enabled |
|
||||
|
||||
## Task Routing
|
||||
|
||||
| Task Type | Model |
|
||||
|-----------|-------|
|
||||
| Reasoning chains | copilot/gpt-5.2 |
|
||||
| Code generation | zai/glm-4.7 |
|
||||
| Long context | gemini/gemini-3-pro |
|
||||
| General/fallback | copilot/sonnet-4.5 |
|
||||
|
||||
## Claude-to-External Mapping
|
||||
|
||||
| Claude Tier | External Equivalent |
|
||||
|-------------|---------------------|
|
||||
| opus | copilot/gpt-5.2 |
|
||||
| sonnet | copilot/sonnet-4.5 |
|
||||
| haiku | copilot/haiku-4.5 |
|
||||
|
||||
## Files to Create
|
||||
|
||||
### `~/.claude/state/external-mode.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": false,
|
||||
"activated_at": null,
|
||||
"reason": null
|
||||
}
|
||||
```
|
||||
|
||||
### `~/.claude/mcp/llm-router/invoke.py`
|
||||
|
||||
Main entry point for invoking external LLMs.
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Invoke external LLM via configured provider.
|
||||
|
||||
Usage:
|
||||
invoke.py --model copilot/gpt-5.2 -p "prompt"
|
||||
invoke.py --task reasoning -p "prompt"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
STATE_DIR = Path.home() / ".claude/state"
|
||||
|
||||
def load_policy():
|
||||
with open(STATE_DIR / "model-policy.json") as f:
|
||||
return json.load(f)
|
||||
|
||||
def resolve_model(args, policy):
|
||||
if args.model:
|
||||
return args.model
|
||||
if args.task and args.task in policy["task_routing"]:
|
||||
return policy["task_routing"][args.task]
|
||||
return policy["task_routing"]["default"]
|
||||
|
||||
def invoke(model: str, prompt: str, policy: dict) -> str:
|
||||
model_config = policy["external_models"][model]
|
||||
cli = model_config["cli"]
|
||||
cli_args = model_config["cli_args"]
|
||||
|
||||
if cli == "opencode":
|
||||
from providers.opencode import invoke as opencode_invoke
|
||||
return opencode_invoke(cli_args, prompt)
|
||||
elif cli == "gemini":
|
||||
from providers.gemini import invoke as gemini_invoke
|
||||
return gemini_invoke(cli_args, prompt)
|
||||
else:
|
||||
raise ValueError(f"Unknown CLI: {cli}")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("-p", "--prompt", required=True, help="Prompt text")
|
||||
parser.add_argument("--model", help="Explicit model (e.g., copilot/gpt-5.2)")
|
||||
parser.add_argument("--task", help="Task type for routing")
|
||||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
policy = load_policy()
|
||||
model = resolve_model(args, policy)
|
||||
result = invoke(args.prompt, model, policy)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps({"model": model, "response": result}))
|
||||
else:
|
||||
print(result)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
### `~/.claude/mcp/llm-router/providers/opencode.py`
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""OpenCode CLI wrapper."""
|
||||
|
||||
import subprocess
|
||||
|
||||
def invoke(cli_args: list, prompt: str) -> str:
|
||||
cmd = ["opencode", "--print"] + cli_args + ["-p", prompt]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"opencode failed: {result.stderr}")
|
||||
|
||||
return result.stdout.strip()
|
||||
```
|
||||
|
||||
### `~/.claude/mcp/llm-router/providers/gemini.py`
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""Gemini CLI wrapper."""
|
||||
|
||||
import subprocess
|
||||
|
||||
def invoke(cli_args: list, prompt: str) -> str:
|
||||
cmd = ["gemini"] + cli_args + ["-p", prompt]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"gemini failed: {result.stderr}")
|
||||
|
||||
return result.stdout.strip()
|
||||
```
|
||||
|
||||
### `~/.claude/mcp/llm-router/delegate.py`
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Agent delegation helper. Routes to external or Claude based on mode.
|
||||
|
||||
Usage:
|
||||
delegate.py --tier sonnet -p "prompt"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
STATE_DIR = Path.home() / ".claude/state"
|
||||
|
||||
def is_external_mode():
|
||||
mode_file = STATE_DIR / "external-mode.json"
|
||||
if mode_file.exists():
|
||||
with open(mode_file) as f:
|
||||
return json.load(f).get("enabled", False)
|
||||
return False
|
||||
|
||||
def delegate(tier: str, prompt: str) -> str:
|
||||
if is_external_mode():
|
||||
policy = json.loads((STATE_DIR / "model-policy.json").read_text())
|
||||
model = policy["claude_to_external_map"][tier]
|
||||
|
||||
result = subprocess.run(
|
||||
[str(Path.home() / ".claude/mcp/llm-router/invoke.py"),
|
||||
"--model", model, "-p", prompt],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return result.stdout
|
||||
else:
|
||||
result = subprocess.run(
|
||||
["claude", "--print", "--model", tier, prompt],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--tier", required=True, choices=["opus", "sonnet", "haiku"])
|
||||
parser.add_argument("-p", "--prompt", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(delegate(args.tier, args.prompt))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### `~/.claude/state/model-policy.json`
|
||||
|
||||
Add sections:
|
||||
|
||||
```json
|
||||
{
|
||||
"external_models": {
|
||||
"copilot/gpt-5.2": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["--provider", "copilot", "--model", "gpt-5.2"],
|
||||
"use_cases": ["reasoning", "fallback"],
|
||||
"tier": "opus-equivalent"
|
||||
},
|
||||
"copilot/sonnet-4.5": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["--provider", "copilot", "--model", "sonnet-4.5"],
|
||||
"use_cases": ["general", "fallback"],
|
||||
"tier": "sonnet-equivalent"
|
||||
},
|
||||
"copilot/haiku-4.5": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["--provider", "copilot", "--model", "haiku-4.5"],
|
||||
"use_cases": ["simple"],
|
||||
"tier": "haiku-equivalent"
|
||||
},
|
||||
"zai/glm-4.7": {
|
||||
"cli": "opencode",
|
||||
"cli_args": ["--provider", "zai", "--model", "glm-4.7"],
|
||||
"use_cases": ["code-generation"],
|
||||
"tier": "sonnet-equivalent"
|
||||
},
|
||||
"gemini/gemini-3-pro": {
|
||||
"cli": "gemini",
|
||||
"cli_args": ["-m", "gemini-3-pro"],
|
||||
"use_cases": ["long-context"],
|
||||
"tier": "opus-equivalent"
|
||||
}
|
||||
},
|
||||
"claude_to_external_map": {
|
||||
"opus": "copilot/gpt-5.2",
|
||||
"sonnet": "copilot/sonnet-4.5",
|
||||
"haiku": "copilot/haiku-4.5"
|
||||
},
|
||||
"task_routing": {
|
||||
"reasoning": "copilot/gpt-5.2",
|
||||
"code-generation": "zai/glm-4.7",
|
||||
"long-context": "gemini/gemini-3-pro",
|
||||
"default": "copilot/sonnet-4.5"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `~/.claude/state/component-registry.json`
|
||||
|
||||
Add trigger for external mode:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "external-mode-toggle",
|
||||
"type": "skill",
|
||||
"triggers": [
|
||||
"use external", "switch to external", "external models",
|
||||
"stop using claude", "external only", "use copilot",
|
||||
"back to claude", "use claude again", "disable external"
|
||||
],
|
||||
"action": "toggle-external-mode"
|
||||
}
|
||||
```
|
||||
|
||||
### `~/.claude/hooks/scripts/session-start.sh`
|
||||
|
||||
Add external mode announcement:
|
||||
|
||||
```bash
|
||||
# Check external mode
|
||||
if [ -f ~/.claude/state/external-mode.json ]; then
|
||||
if [ "$(jq -r '.enabled' ~/.claude/state/external-mode.json)" = "true" ]; then
|
||||
echo "external-mode:enabled"
|
||||
fi
|
||||
fi
|
||||
```
|
||||
|
||||
## Toggle Interface
|
||||
|
||||
### Command-based
|
||||
|
||||
```
|
||||
/pa --external on # Enable external-only mode
|
||||
/pa --external off # Disable, return to Claude
|
||||
/pa --external status # Show current mode
|
||||
```
|
||||
|
||||
### Natural language
|
||||
|
||||
| User says | Action |
|
||||
|-----------|--------|
|
||||
| "switch to external models" | Enable |
|
||||
| "use copilot for everything" | Enable |
|
||||
| "go back to claude" | Disable |
|
||||
| "are we using external?" | Status |
|
||||
| "use external for this" | One-shot (no persist) |
|
||||
|
||||
### Visual indicator
|
||||
|
||||
When external mode active, PA prefixes responses:
|
||||
|
||||
```
|
||||
🔌 [External: copilot/gpt-5.2]
|
||||
|
||||
<response>
|
||||
```
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Create `external-mode.json` state file
|
||||
2. Extend `model-policy.json` with external models
|
||||
3. Create `llm-router/` directory and scripts
|
||||
4. Add provider wrappers (opencode, gemini)
|
||||
5. Create delegation helper
|
||||
6. Update PA with toggle commands
|
||||
7. Add component-registry triggers
|
||||
8. Update session-start hook
|
||||
9. Test each provider
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
# Test router directly
|
||||
~/.claude/mcp/llm-router/invoke.py --model copilot/gpt-5.2 -p "Say hello"
|
||||
|
||||
# Test delegation
|
||||
~/.claude/mcp/llm-router/delegate.py --tier sonnet -p "What is 2+2?"
|
||||
|
||||
# Test toggle
|
||||
/pa --external on
|
||||
/pa what time is it?
|
||||
/pa --external off
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
If issues arise:
|
||||
1. Set `external-mode.json` → `enabled: false`
|
||||
2. All operations revert to Claude immediately
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- Auto-fallback to external when Claude rate-limited
|
||||
- Cost tracking per external model
|
||||
- Response quality comparison metrics
|
||||
- Additional providers (Mistral, local Ollama)
|
||||
@@ -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.
|
||||
@@ -0,0 +1,230 @@
|
||||
# Plan: Add Plan Status Tracking
|
||||
|
||||
## Problem
|
||||
|
||||
Plans in `~/.claude/plans/` have inconsistent status tracking:
|
||||
- Some have inline `**Status:** Implemented`
|
||||
- Most have no status marker
|
||||
- No central index to query plan statuses
|
||||
|
||||
## Solution
|
||||
|
||||
Create `~/.claude/plans/index.json` as a central registry for plan metadata and status.
|
||||
|
||||
## Design
|
||||
|
||||
### Index Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"plans": {
|
||||
"temporal-foraging-milner": {
|
||||
"title": "RAG JSON-to-text transformation",
|
||||
"status": "pending",
|
||||
"created": "2026-01-05",
|
||||
"category": "enhancement"
|
||||
},
|
||||
"fizzy-puzzling-candy": {
|
||||
"title": "Session summarization hook",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-03",
|
||||
"implemented": "2026-01-03",
|
||||
"category": "feature"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Status Values
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| `pending` | Not yet implemented |
|
||||
| `implemented` | Fully implemented |
|
||||
| `partial` | Partially implemented |
|
||||
| `abandoned` | Decided not to implement |
|
||||
| `superseded` | Replaced by another plan |
|
||||
|
||||
### Categories
|
||||
|
||||
| Category | Meaning |
|
||||
|----------|---------|
|
||||
| `feature` | New capability |
|
||||
| `enhancement` | Improve existing feature |
|
||||
| `bugfix` | Fix an issue |
|
||||
| `diagnostic` | One-time investigation (auto-complete) |
|
||||
| `design` | Design document for reference |
|
||||
|
||||
## Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `~/.claude/plans/index.json` | Central plan registry |
|
||||
|
||||
## Implementation
|
||||
|
||||
### Step 1: Create index.json with all current plans
|
||||
|
||||
Populate based on our verification:
|
||||
|
||||
**Implemented:**
|
||||
- wise-dazzling-marshmallow (k8s quick-status)
|
||||
- fizzy-puzzling-candy (session summarization)
|
||||
- shimmering-discovering-bonbon (linux sysadmin agent)
|
||||
- valiant-hugging-dahl (pi50 optimization)
|
||||
- cozy-strolling-nygaard (status line + keybind)
|
||||
- flickering-enchanting-fiddle (restructure components)
|
||||
- velvet-percolating-porcupine (no-redundancy rule)
|
||||
- 2025-01-02-gcal-design
|
||||
- 2026-01-01-component-registry-design
|
||||
- 2026-01-01-usage-tracking-design
|
||||
|
||||
**Diagnostic (complete):**
|
||||
- elegant-prancing-allen (vulkan verification)
|
||||
- pure-wishing-metcalfe (cluster diagnosis)
|
||||
- glistening-wondering-wadler (structure verification)
|
||||
|
||||
**Pending:**
|
||||
- temporal-foraging-milner (RAG improvement)
|
||||
- cosmic-frolicking-compass (Zed Wayland)
|
||||
|
||||
**Handoff doc (reference only):**
|
||||
- shimmering-discovering-bonbon-handoff
|
||||
|
||||
### Step 2: Update CLAUDE.md
|
||||
|
||||
Add plans index to state files table.
|
||||
|
||||
## Benefits
|
||||
|
||||
1. Single source of truth for plan statuses
|
||||
2. Easy to query: `jq '.plans | to_entries[] | select(.value.status == "pending")' index.json`
|
||||
3. No need to modify individual plan files
|
||||
4. Can track implementation dates
|
||||
|
||||
## Full index.json Content
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"description": "Plan status registry",
|
||||
"plans": {
|
||||
"temporal-foraging-milner": {
|
||||
"title": "RAG JSON-to-text transformation",
|
||||
"status": "pending",
|
||||
"created": "2026-01-05",
|
||||
"category": "enhancement"
|
||||
},
|
||||
"cosmic-frolicking-compass": {
|
||||
"title": "Zed Wayland compilation",
|
||||
"status": "pending",
|
||||
"created": "2025-12-13",
|
||||
"category": "enhancement",
|
||||
"notes": "External task - compile Zed with Wayland support"
|
||||
},
|
||||
"wise-dazzling-marshmallow": {
|
||||
"title": "K8s quick-status skill",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-28",
|
||||
"implemented": "2025-12-29",
|
||||
"category": "feature"
|
||||
},
|
||||
"fizzy-puzzling-candy": {
|
||||
"title": "Session summarization hook",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-03",
|
||||
"implemented": "2026-01-03",
|
||||
"category": "feature"
|
||||
},
|
||||
"shimmering-discovering-bonbon": {
|
||||
"title": "Linux sysadmin agent",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-28",
|
||||
"implemented": "2025-12-28",
|
||||
"category": "feature"
|
||||
},
|
||||
"valiant-hugging-dahl": {
|
||||
"title": "Pi50 resource optimization",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-05",
|
||||
"implemented": "2026-01-05",
|
||||
"category": "enhancement"
|
||||
},
|
||||
"cozy-strolling-nygaard": {
|
||||
"title": "Status line + keybind fix",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-29",
|
||||
"implemented": "2025-12-29",
|
||||
"category": "bugfix"
|
||||
},
|
||||
"flickering-enchanting-fiddle": {
|
||||
"title": "Restructure components",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-28",
|
||||
"implemented": "2025-12-28",
|
||||
"category": "enhancement"
|
||||
},
|
||||
"velvet-percolating-porcupine": {
|
||||
"title": "No-redundancy rule",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-28",
|
||||
"implemented": "2025-12-28",
|
||||
"category": "enhancement"
|
||||
},
|
||||
"elegant-prancing-allen": {
|
||||
"title": "Vulkan verification",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-12",
|
||||
"implemented": "2025-12-12",
|
||||
"category": "diagnostic"
|
||||
},
|
||||
"pure-wishing-metcalfe": {
|
||||
"title": "Cluster issue diagnosis",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-27",
|
||||
"implemented": "2025-12-27",
|
||||
"category": "diagnostic"
|
||||
},
|
||||
"glistening-wondering-wadler": {
|
||||
"title": "Structure verification report",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-03",
|
||||
"implemented": "2026-01-03",
|
||||
"category": "diagnostic"
|
||||
},
|
||||
"2025-01-02-gcal-design": {
|
||||
"title": "Google Calendar integration",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-31",
|
||||
"implemented": "2026-01-01",
|
||||
"category": "design"
|
||||
},
|
||||
"2026-01-01-component-registry-design": {
|
||||
"title": "Component registry",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-01",
|
||||
"implemented": "2026-01-01",
|
||||
"category": "design"
|
||||
},
|
||||
"2026-01-01-usage-tracking-design": {
|
||||
"title": "Usage tracking",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-31",
|
||||
"implemented": "2026-01-01",
|
||||
"category": "design"
|
||||
},
|
||||
"shimmering-discovering-bonbon-handoff": {
|
||||
"title": "Linux sysadmin handoff doc",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-28",
|
||||
"implemented": "2025-12-28",
|
||||
"category": "design",
|
||||
"notes": "Reference document for shimmering-discovering-bonbon"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Commit
|
||||
|
||||
Single commit: "Add plans index.json for status tracking"
|
||||
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"description": "Plan status registry",
|
||||
"plans": {
|
||||
"temporal-foraging-milner": {
|
||||
"title": "RAG JSON-to-text transformation",
|
||||
"status": "pending",
|
||||
"created": "2026-01-05",
|
||||
"category": "enhancement"
|
||||
},
|
||||
"cosmic-frolicking-compass": {
|
||||
"title": "Zed Wayland compilation",
|
||||
"status": "pending",
|
||||
"created": "2025-12-13",
|
||||
"category": "enhancement",
|
||||
"notes": "External task - compile Zed with Wayland support"
|
||||
},
|
||||
"wise-dazzling-marshmallow": {
|
||||
"title": "K8s quick-status skill",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-28",
|
||||
"implemented": "2025-12-29",
|
||||
"category": "feature"
|
||||
},
|
||||
"fizzy-puzzling-candy": {
|
||||
"title": "Session summarization hook",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-03",
|
||||
"implemented": "2026-01-03",
|
||||
"category": "feature"
|
||||
},
|
||||
"shimmering-discovering-bonbon": {
|
||||
"title": "Linux sysadmin agent",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-28",
|
||||
"implemented": "2025-12-28",
|
||||
"category": "feature"
|
||||
},
|
||||
"valiant-hugging-dahl": {
|
||||
"title": "Pi50 resource optimization",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-05",
|
||||
"implemented": "2026-01-05",
|
||||
"category": "enhancement"
|
||||
},
|
||||
"cozy-strolling-nygaard": {
|
||||
"title": "Status line + keybind fix",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-29",
|
||||
"implemented": "2025-12-29",
|
||||
"category": "bugfix"
|
||||
},
|
||||
"flickering-enchanting-fiddle": {
|
||||
"title": "Restructure components",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-28",
|
||||
"implemented": "2025-12-28",
|
||||
"category": "enhancement"
|
||||
},
|
||||
"velvet-percolating-porcupine": {
|
||||
"title": "No-redundancy rule",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-28",
|
||||
"implemented": "2025-12-28",
|
||||
"category": "enhancement"
|
||||
},
|
||||
"elegant-prancing-allen": {
|
||||
"title": "Vulkan verification",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-12",
|
||||
"implemented": "2025-12-12",
|
||||
"category": "diagnostic"
|
||||
},
|
||||
"pure-wishing-metcalfe": {
|
||||
"title": "Cluster issue diagnosis",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-27",
|
||||
"implemented": "2025-12-27",
|
||||
"category": "diagnostic"
|
||||
},
|
||||
"glistening-wondering-wadler": {
|
||||
"title": "Structure verification report",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-03",
|
||||
"implemented": "2026-01-03",
|
||||
"category": "diagnostic"
|
||||
},
|
||||
"2025-01-02-gcal-design": {
|
||||
"title": "Google Calendar integration",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-31",
|
||||
"implemented": "2026-01-01",
|
||||
"category": "design"
|
||||
},
|
||||
"2026-01-01-component-registry-design": {
|
||||
"title": "Component registry",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-01",
|
||||
"implemented": "2026-01-01",
|
||||
"category": "design"
|
||||
},
|
||||
"2026-01-01-usage-tracking-design": {
|
||||
"title": "Usage tracking",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-31",
|
||||
"implemented": "2026-01-01",
|
||||
"category": "design"
|
||||
},
|
||||
"shimmering-discovering-bonbon-handoff": {
|
||||
"title": "Linux sysadmin handoff doc",
|
||||
"status": "implemented",
|
||||
"created": "2025-12-28",
|
||||
"implemented": "2025-12-28",
|
||||
"category": "design",
|
||||
"notes": "Reference document for shimmering-discovering-bonbon"
|
||||
},
|
||||
"golden-imagining-engelbart": {
|
||||
"title": "Plan status tracking",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-07",
|
||||
"implemented": "2026-01-07",
|
||||
"category": "enhancement",
|
||||
"notes": "This plan - meta!"
|
||||
},
|
||||
"gleaming-routing-mercury": {
|
||||
"title": "External LLM integration",
|
||||
"status": "implemented",
|
||||
"created": "2026-01-08",
|
||||
"implemented": "2026-01-08",
|
||||
"category": "feature",
|
||||
"notes": "fc-004 - Cloud API integration via opencode/gemini CLIs with session toggle"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
# Implementation Plan: OpenCode Claude Sync Enhancements
|
||||
|
||||
## Overview
|
||||
|
||||
Transpose Claude Code agent/skill setup to OpenCode in parallel, per decisions from brainstorming session (`enumerated-giggling-scone.md`).
|
||||
|
||||
## Key Decisions (from brainstorming)
|
||||
|
||||
| Decision | Value |
|
||||
|----------|-------|
|
||||
| Primary agent | Use built-in `build` (don't port PA) |
|
||||
| Agents to skip | `personal-assistant`, `master-orchestrator` |
|
||||
| Other agents | All become `mode: subagent` |
|
||||
| Model inheritance | Use `model: inherit` for subagents |
|
||||
| State sharing | Reference via `instructions`, don't copy |
|
||||
| Source of truth | Claude Code (`~/.claude/`) |
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `~/.config/opencode/scripts/claude_sync.py` - Main sync script
|
||||
2. `~/.config/opencode/opencode.json` - Config file
|
||||
|
||||
## Files to Create
|
||||
|
||||
1. `~/.config/opencode/README.md` - Documentation
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Backup (DONE)
|
||||
|
||||
Created backups:
|
||||
- `~/.config/opencode-backup-20260107_120135.tar.gz`
|
||||
- `~/opencode-home-backup-20260107_120136.tar.gz`
|
||||
|
||||
### Step 2: Enhance `claude_sync.py`
|
||||
|
||||
**Location**: `~/.config/opencode/scripts/claude_sync.py`
|
||||
|
||||
**Modifications**:
|
||||
|
||||
1. Add constants near top of file:
|
||||
```python
|
||||
SKIP_AGENTS = {"personal-assistant", "master-orchestrator"}
|
||||
|
||||
MODEL_MAP = {
|
||||
"opus": "anthropic/claude-opus-4",
|
||||
"sonnet": "anthropic/claude-sonnet-4-5",
|
||||
"haiku": "anthropic/claude-haiku-4-5",
|
||||
}
|
||||
```
|
||||
|
||||
2. Modify `transform_frontmatter()` for agents:
|
||||
- Check if agent name in `SKIP_AGENTS`, return `None` to signal skip
|
||||
- Add `frontmatter["mode"] = "subagent"`
|
||||
- Set `frontmatter["model"] = "inherit"`
|
||||
- Map explicit models using `MODEL_MAP`
|
||||
|
||||
3. Modify `sync_tree()` to handle `None` return from transform (skip file)
|
||||
|
||||
4. Update `expected_dest_paths_for_tree()` to exclude skipped agents
|
||||
|
||||
### Step 3: Run Sync
|
||||
|
||||
```bash
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py --dry-run
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py
|
||||
python3 ~/.config/opencode/scripts/claude_sync.py --clean --apply
|
||||
```
|
||||
|
||||
### Step 4: Update `opencode.json`
|
||||
|
||||
Add to existing config:
|
||||
```json
|
||||
{
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
"small_model": "anthropic/claude-haiku-4-5",
|
||||
"instructions": [
|
||||
"~/.claude/CLAUDE.md",
|
||||
"~/.claude/state/kb.json",
|
||||
"~/.claude/state/personal-assistant/memory/facts.json",
|
||||
"~/.claude/state/personal-assistant/memory/preferences.json"
|
||||
],
|
||||
"permission": {
|
||||
"edit": "ask",
|
||||
"bash": {
|
||||
"*": "ask",
|
||||
"pacman -Q*": "allow",
|
||||
"systemctl status*": "allow",
|
||||
"kubectl get*": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Test
|
||||
|
||||
- Run `opencode` and verify skill discovery
|
||||
- Test `@linux-sysadmin` subagent invocation
|
||||
- Verify permissions work
|
||||
|
||||
### Step 6: Create README.md
|
||||
|
||||
Document:
|
||||
- Architecture (Claude Code as source of truth)
|
||||
- Sync workflow
|
||||
- Agent mapping table
|
||||
- How to invoke subagents
|
||||
|
||||
### Step 7: Add Future Consideration
|
||||
|
||||
Add entry to `~/.claude/state/future-considerations.json` about JSON minification for large instruction files.
|
||||
|
||||
## Estimated Time
|
||||
|
||||
~2 hours total (Step 1 already done)
|
||||
@@ -0,0 +1,110 @@
|
||||
# Plan: Improve RAG Personal Index JSON-to-Natural-Language Transformation
|
||||
|
||||
## Problem
|
||||
|
||||
The RAG personal index produces low-quality matches for semantic queries because it indexes raw JSON structure rather than natural language.
|
||||
|
||||
**Example failure:**
|
||||
- Query: "how to add a new agent"
|
||||
- Expected: Match `system-instructions.json` → `processes.agent-lifecycle.add`
|
||||
- Actual: Score 0.479, returns generic agent mentions instead
|
||||
|
||||
**Root cause:** The chunker doesn't recognize process structures with `add`/`remove`/`rules`/`requirements` arrays, so they fall through to raw JSON stringification.
|
||||
|
||||
## Solution
|
||||
|
||||
Enhance `index_personal.py` to transform JSON structures into natural language at index time.
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `~/.claude/skills/rag-search/scripts/index_personal.py` - Main changes
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. Add Process Pattern Recognition (lines ~127-138)
|
||||
|
||||
Add handling for process objects with action arrays:
|
||||
|
||||
```python
|
||||
# Process with action arrays (add, remove, rules, requirements, etc.)
|
||||
action_keys = ["add", "remove", "rules", "requirements", "steps", "validate"]
|
||||
if any(key in item for key in action_keys):
|
||||
parts = []
|
||||
if context:
|
||||
parts.append(f"{context}:")
|
||||
if item.get("description"):
|
||||
parts.append(item["description"])
|
||||
|
||||
for action_key in action_keys:
|
||||
if action_key in item and isinstance(item[action_key], list):
|
||||
action_text = f"To {action_key}: " + ". ".join(item[action_key])
|
||||
parts.append(action_text)
|
||||
|
||||
if parts:
|
||||
yield (" ".join(parts), {**base_metadata, "process": context})
|
||||
return
|
||||
```
|
||||
|
||||
### 2. Improve Context Propagation
|
||||
|
||||
When processing nested dicts, pass richer context:
|
||||
|
||||
```python
|
||||
# In the top-level dict processing (line ~154-161)
|
||||
elif isinstance(value, dict):
|
||||
# Pass the key as context for better chunk text
|
||||
yield from process_item(value, context=key)
|
||||
```
|
||||
|
||||
Already done, but ensure action arrays get the context.
|
||||
|
||||
### 3. Handle Key-Value Pairs in Processes
|
||||
|
||||
For structures like:
|
||||
```json
|
||||
"content-principles": {
|
||||
"no-redundancy": "Information lives in one authoritative location",
|
||||
"lean-files": "Keep files concise..."
|
||||
}
|
||||
```
|
||||
|
||||
Transform to: `"content-principles: no-redundancy means information lives in one authoritative location. lean-files means keep files concise..."`
|
||||
|
||||
### 4. Add Tests
|
||||
|
||||
Create a simple test to verify transformation quality:
|
||||
|
||||
```bash
|
||||
# After reindex, verify the failing query now works
|
||||
~/.claude/skills/rag-search/scripts/search.py "how to add a new agent" --index personal
|
||||
# Should return system-instructions.json with score > 0.7
|
||||
```
|
||||
|
||||
## Expected Outcome
|
||||
|
||||
| Query | Before | After |
|
||||
|-------|--------|-------|
|
||||
| "how to add a new agent" | 0.479, wrong file | >0.7, system-instructions.json |
|
||||
| "agent lifecycle" | Similar | Better match to process |
|
||||
| "model selection rules" | Depends | Match model-selection process |
|
||||
|
||||
## Validation Steps
|
||||
|
||||
1. Run modified indexer
|
||||
2. Test the three queries above
|
||||
3. Compare scores and result relevance
|
||||
|
||||
## Rollback
|
||||
|
||||
If results degrade: `git checkout scripts/index_personal.py && reindex`
|
||||
|
||||
## Post-Implementation
|
||||
|
||||
Add to `future-considerations.json`:
|
||||
- RAG indexer debug/verbose mode to inspect what text is being indexed
|
||||
|
||||
## Future Considerations (Deferred)
|
||||
|
||||
- Natural language templates per JSON schema type
|
||||
- LLM-generated summaries of complex structures
|
||||
- Caching transformed text alongside original JSON
|
||||
@@ -0,0 +1,171 @@
|
||||
# Plan: Improve pi50 (Control Plane) Resource Usage
|
||||
|
||||
## Problem Summary
|
||||
|
||||
pi50 (control plane) is running at **73% CPU / 81% memory** while worker nodes have significant headroom:
|
||||
- pi3: 7% CPU / 65% memory (but only 800MB RAM - memory constrained)
|
||||
- pi51: 18% CPU / 64% memory (8GB RAM - plenty of capacity)
|
||||
|
||||
**Root cause**: pi50 has **NO control-plane taint**, so the scheduler treats it as a general worker node. It currently runs ~85 pods vs 38 on pi51.
|
||||
|
||||
## Current State
|
||||
|
||||
| Node | Role | CPUs | Memory | CPU Used | Mem Used | Pods |
|
||||
|------|------|------|--------|----------|----------|------|
|
||||
| pi50 | control-plane | 4 | 8GB | 73% | 81% | ~85 |
|
||||
| pi3 | worker | 4 | 800MB | 7% | 65% | 13 |
|
||||
| pi51 | worker | 4 | 8GB | 18% | 64% | 38 |
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
### Option A: Add PreferNoSchedule Taint (Recommended)
|
||||
|
||||
Add a soft taint to pi50 that tells the scheduler to prefer other nodes for new workloads, while allowing existing pods to remain.
|
||||
|
||||
```bash
|
||||
kubectl taint nodes pi50 node-role.kubernetes.io/control-plane=:PreferNoSchedule
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Non-disruptive - existing pods continue running
|
||||
- New pods will prefer pi51/pi3
|
||||
- Gradual rebalancing as pods are recreated
|
||||
- Easy to remove if needed
|
||||
|
||||
**Cons:**
|
||||
- Won't immediately reduce load
|
||||
- Existing pods stay where they are
|
||||
|
||||
### Option B: Move Heavy Workloads Immediately
|
||||
|
||||
Identify and relocate the heaviest workloads from pi50 to pi51:
|
||||
|
||||
**Top CPU consumers on pi50:**
|
||||
1. ArgoCD application-controller (157m CPU, 364Mi) - should stay (manages cluster)
|
||||
2. Longhorn instance-manager (139m CPU, 707Mi) - must stay (storage)
|
||||
3. ai-stack workloads (ollama, litellm, open-webui, etc.)
|
||||
|
||||
**Candidates to move to pi51:**
|
||||
- `ai-stack/ollama` - can run on any node with storage
|
||||
- `ai-stack/litellm` - stateless, can move
|
||||
- `ai-stack/open-webui` - can move
|
||||
- `ai-stack/claude-code`, `codex`, `gemini-cli`, `opencode` - can move
|
||||
- `minio` - can move (uses PVC)
|
||||
- `pihole2` - can move
|
||||
|
||||
**Method**: Add `nodeSelector` or `nodeAffinity` to deployments:
|
||||
```yaml
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: pi51
|
||||
```
|
||||
|
||||
Or use anti-affinity to avoid pi50:
|
||||
```yaml
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
preference:
|
||||
matchExpressions:
|
||||
- key: node-role.kubernetes.io/control-plane
|
||||
operator: DoesNotExist
|
||||
```
|
||||
|
||||
### Option C: Combined Approach (Best)
|
||||
|
||||
1. Add `PreferNoSchedule` taint to pi50 (prevents future imbalance)
|
||||
2. Immediately move 2-3 heaviest moveable workloads to pi51
|
||||
3. Let remaining workloads naturally migrate over time
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### Step 1: Add taint to pi50
|
||||
```bash
|
||||
kubectl taint nodes pi50 node-role.kubernetes.io/control-plane=:PreferNoSchedule
|
||||
```
|
||||
|
||||
### Step 2: Verify existing workloads still running
|
||||
```bash
|
||||
kubectl get pods -A -o wide --field-selector spec.nodeName=pi50 | grep -v Running
|
||||
```
|
||||
|
||||
### Step 3: Move heavy ai-stack workloads (optional, for immediate relief)
|
||||
|
||||
For each deployment to move, patch with node anti-affinity or selector:
|
||||
```bash
|
||||
kubectl patch deployment -n ai-stack ollama --type=merge -p '{"spec":{"template":{"spec":{"nodeSelector":{"kubernetes.io/hostname":"pi51"}}}}}'
|
||||
```
|
||||
|
||||
Or delete pods to trigger rescheduling (if PreferNoSchedule taint is set):
|
||||
```bash
|
||||
kubectl delete pod -n ai-stack <pod-name>
|
||||
```
|
||||
|
||||
### Step 4: Monitor
|
||||
```bash
|
||||
kubectl top nodes
|
||||
```
|
||||
|
||||
## Workloads That MUST Stay on pi50
|
||||
|
||||
- `kube-system/*` - Core cluster components
|
||||
- `longhorn-system/csi-*` - Storage controllers
|
||||
- `longhorn-system/longhorn-driver-deployer` - Storage management
|
||||
- `local-path-storage/*` - Local storage provisioner
|
||||
|
||||
## Expected Outcome
|
||||
|
||||
After changes:
|
||||
- pi50: ~50-60% CPU, ~65-70% memory (control plane + essential services)
|
||||
- pi51: ~40-50% CPU, ~70-75% memory (absorbs application workloads)
|
||||
- New pods prefer pi51 automatically
|
||||
|
||||
## Risks
|
||||
|
||||
- **Low**: PreferNoSchedule is a soft taint - pods with tolerations can still schedule on pi50
|
||||
- **Low**: Moving workloads may cause brief service interruption during pod recreation
|
||||
- **Note**: pi3 cannot absorb much due to 800MB RAM limit
|
||||
|
||||
## Selected Approach: A + B (Combined)
|
||||
|
||||
User selected combined approach:
|
||||
1. Add `PreferNoSchedule` taint to pi50
|
||||
2. Move heavy ai-stack workloads to pi51 immediately
|
||||
|
||||
## Execution Plan
|
||||
|
||||
### Phase 1: Add Taint
|
||||
```bash
|
||||
kubectl taint nodes pi50 node-role.kubernetes.io/control-plane=:PreferNoSchedule
|
||||
```
|
||||
|
||||
### Phase 2: Move Heavy Workloads to pi51
|
||||
|
||||
Target workloads (heaviest on pi50):
|
||||
- `ai-stack/ollama`
|
||||
- `ai-stack/open-webui`
|
||||
- `ai-stack/litellm`
|
||||
- `ai-stack/claude-code`
|
||||
- `ai-stack/codex`
|
||||
- `ai-stack/gemini-cli`
|
||||
- `ai-stack/opencode`
|
||||
- `ai-stack/searxng`
|
||||
- `minio/minio`
|
||||
|
||||
Method: Delete pods to trigger rescheduling (taint will push them to pi51):
|
||||
```bash
|
||||
kubectl delete pod -n ai-stack -l app.kubernetes.io/name=ollama
|
||||
# etc for each workload
|
||||
```
|
||||
|
||||
### Phase 3: Verify
|
||||
```bash
|
||||
kubectl top nodes
|
||||
kubectl get pods -A -o wide | grep -E "ollama|open-webui|litellm"
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
"frontend-design@claude-plugins-official": [
|
||||
{
|
||||
"scope": "user",
|
||||
"installPath": "/home/will/.claude/plugins/cache/claude-plugins-official/frontend-design/6d3752c000e2",
|
||||
"version": "6d3752c000e2",
|
||||
"installPath": "/home/will/.claude/plugins/cache/claude-plugins-official/frontend-design/b97f6eadd929",
|
||||
"version": "b97f6eadd929",
|
||||
"installedAt": "2025-12-24T19:08:12.422Z",
|
||||
"lastUpdated": "2025-12-24T19:08:12.422Z",
|
||||
"lastUpdated": "2026-01-07T08:00:06.726Z",
|
||||
"gitCommitSha": "6d3752c000e2b3d0e6137bd7adb04895d6f40f14",
|
||||
"isLocal": true
|
||||
}
|
||||
@@ -26,10 +26,10 @@
|
||||
"commit-commands@claude-plugins-official": [
|
||||
{
|
||||
"scope": "user",
|
||||
"installPath": "/home/will/.claude/plugins/cache/claude-plugins-official/commit-commands/6d3752c000e2",
|
||||
"version": "6d3752c000e2",
|
||||
"installPath": "/home/will/.claude/plugins/cache/claude-plugins-official/commit-commands/b97f6eadd929",
|
||||
"version": "b97f6eadd929",
|
||||
"installedAt": "2025-12-24T19:10:05.451Z",
|
||||
"lastUpdated": "2025-12-24T19:10:36.843Z",
|
||||
"lastUpdated": "2026-01-07T08:00:06.734Z",
|
||||
"isLocal": true
|
||||
}
|
||||
],
|
||||
@@ -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/883f2ba69e50",
|
||||
"version": "883f2ba69e50",
|
||||
"installedAt": "2026-01-02T19:47:02.395Z",
|
||||
"lastUpdated": "2026-01-06T20:00:15.709Z",
|
||||
"gitCommitSha": "de89f3066c68d7a2f2d4190173fa46c26e2f30fd",
|
||||
"isLocal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"repo": "anthropics/claude-plugins-official"
|
||||
},
|
||||
"installLocation": "/home/will/.claude/plugins/marketplaces/claude-plugins-official",
|
||||
"lastUpdated": "2026-01-01T10:11:34.804Z"
|
||||
"lastUpdated": "2026-01-07T19:06:34.488Z"
|
||||
},
|
||||
"superpowers-marketplace": {
|
||||
"source": {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Morning Report - Sat Jan 03, 2026
|
||||
|
||||
## 🌤 Weather
|
||||
Weather unavailable: <urlopen error _ssl.c:1063: The handshake operation timed out>
|
||||
|
||||
## 📧 Email
|
||||
10 unread, 2 attention-worthy
|
||||
|
||||
- [!] Google - Help strengthen security of your Account
|
||||
- [!] coreweave@myworkday.com - Security Alert: Signon from New Device (2x)
|
||||
- E*TRADE - Your Statement Is Now Available
|
||||
- Capital One - Your requested balance summary
|
||||
- Mindful Support Services - Your statement is now available
|
||||
|
||||
## 📅 Today
|
||||
• 2:00 PM - Seattle Saturday (SAM + QED + Lecosho) (5h)
|
||||
Tomorrow: 1 event, first at 2:00 PM
|
||||
|
||||
## 📈 Stocks
|
||||
CRWV $79.32 +10.8% ▲ NVDA $188.85 +1.3% ▲ MSFT $472.94 -2.2% ▼
|
||||
|
||||
## ✅ Tasks
|
||||
⚠️ Could not fetch tasks: ('invalid_scope: Bad Request', {'error': 'invalid_scope', 'error_description': 'Bad Request'})
|
||||
|
||||
## 🖥 Infrastructure
|
||||
K8s: 🟢 | Workstation: 🟢
|
||||
|
||||
## 📰 Tech News
|
||||
• ParadeDB (YC S23) Is Hiring Database Engineers (Hacker News)
|
||||
• X-Clacks-Overhead (Hacker News)
|
||||
• I'm brave enough to say it: Linux is good now, and if you wa... (Lobsters)
|
||||
• Who's Hiring? Q1 2026 (Lobsters)
|
||||
|
||||
---
|
||||
*Generated: 2026-01-03 08:00:20 PT*
|
||||
@@ -0,0 +1,30 @@
|
||||
# Morning Report - Sun Jan 04, 2026
|
||||
|
||||
## 🌤 Weather
|
||||
Seattle: 51°F, Partly cloudy | High 52° Low 43°
|
||||
|
||||
## 📧 Email
|
||||
15 unread
|
||||
• Capital One | Quicks - Your requested balance summary
|
||||
• Uber Receipts - [Personal] Your Saturday evening trip wi
|
||||
• Experian - William, it's time to check your utiliza
|
||||
• Experteer Search Age - William, we have 2 new opportunities for
|
||||
• Chase - You can start your mortgage preapproval
|
||||
|
||||
## 📅 Today
|
||||
• 2:00 PM - Seattle Saturday (SAM + QED + Lecosho) (5h)
|
||||
|
||||
## 📈 Stocks
|
||||
CRWV $79.32 +10.8% ▲ NVDA $188.85 +1.3% ▲ MSFT $472.94 -2.2% ▼
|
||||
|
||||
## 🖥 Infrastructure
|
||||
K8s: 🟢 | Workstation: 🟢
|
||||
|
||||
## 📰 Tech News
|
||||
• C-Sentinel: System prober that captures "system fingerprints... (Hacker News)
|
||||
• Show HN: An LLM-Powered PCB Schematic Checker (Major Update) (Hacker News)
|
||||
• Can I finally start using Wayland in 2026? (Lobsters)
|
||||
• Saying goodbye to the servers at our physical datacenter (Lobsters)
|
||||
|
||||
---
|
||||
*Generated: 2026-01-04 14:40:48 PT*
|
||||
@@ -0,0 +1,27 @@
|
||||
# Morning Report - Mon Jan 05, 2026
|
||||
|
||||
## 🌤 Weather
|
||||
Overcast, 44°F (feels 41°F), rain likely—bring umbrella ☔
|
||||
|
||||
## 📧 Email
|
||||
⚠️ Could not fetch emails: No module named 'pydantic_core._pydantic_core'
|
||||
|
||||
## 📅 Today
|
||||
⚠️ Could not fetch calendar: No module named 'pydantic_core._pydantic_core'
|
||||
|
||||
## 📈 Stocks
|
||||
CRWV $77.64 ▼2.1% | NVDA $187.84 ▼0.5% | MSFT $473.50 ▲0.1%
|
||||
|
||||
## 🖥 Infrastructure
|
||||
K8s: 🟡 | Workstation: 🟢
|
||||
└ K8s: 2 pods not running
|
||||
|
||||
## 📰 Tech News
|
||||
• O-Ring Automation (Hacker News)
|
||||
• Novo Nordisk launches Wegovy weight-loss pill in US, triggering price war (Hacker News)
|
||||
• Refactoring – Not on the backlog (Hacker News)
|
||||
• It's hard to justify Tahoe icons (Lobsters)
|
||||
• Databases in 2025: A Year in Review (Lobsters)
|
||||
|
||||
---
|
||||
*Generated: 2026-01-05 12:44:47 PT*
|
||||
@@ -0,0 +1,31 @@
|
||||
# Morning Report - Tue Jan 06, 2026
|
||||
|
||||
## 🌤 Weather
|
||||
Seattle: 43°F, Light rain, mist | High 45° Low 38°
|
||||
|
||||
## 📧 Email
|
||||
15 unread
|
||||
• Capital One | Quicks - Your requested balance summary
|
||||
• Chase - Your Chase Freedom Unlimited Visa balanc
|
||||
• Experian - William, check out these cards with an i
|
||||
• Delta Air Lines - Discover An Experience Curated For Membe
|
||||
• DoorDash - Save up to $10 on groceries and more eac
|
||||
|
||||
## 📅 Today
|
||||
No events today
|
||||
|
||||
## 📈 Stocks
|
||||
CRWV $77.94 +1.4% ▲ NVDA $187.24 -0.5% ▼ MSFT $478.51 +1.2% ▲
|
||||
|
||||
## 🖥 Infrastructure
|
||||
K8s: 🟡 | Workstation: 🟢
|
||||
└ K8s: 2 pods not running
|
||||
|
||||
## 📰 Tech News
|
||||
• Comparing AI agents to cybersecurity professionals in real-w... (Hacker News)
|
||||
• Oral microbiome sequencing after taking probiotics (Hacker News)
|
||||
• The Best Line Length is 88 (Lobsters)
|
||||
• There Were BGP Anomalies During The Venezuela Blackout (Lobsters)
|
||||
|
||||
---
|
||||
*Generated: 2026-01-06 13:40:51 PT*
|
||||
@@ -0,0 +1,42 @@
|
||||
# Morning Report - Wed Jan 07, 2026
|
||||
|
||||
## 🌤 Weather
|
||||
Overcast 40°F (feels 33°F), 86% humidity, rain possible — bring umbrella
|
||||
|
||||
## 📧 Email
|
||||
10 unread, no urgent items
|
||||
|
||||
• Experian Alerts - Your FICO® Score went up. Nice work!
|
||||
• Experteer - 3 new opportunities for "AWS Architect"
|
||||
• Experian - December spending report is here
|
||||
• Chase - Freedom Unlimited balance is $538.97
|
||||
• Chase - Rewards balance has reached 0 POINTS
|
||||
|
||||
## 📅 Today
|
||||
No events today
|
||||
|
||||
## 📈 Stocks
|
||||
▲ CRWV $78.98 (+1.3%) | ▲ NVDA $189.74 (+1.3%) | ▲ MSFT $488.96 (+2.2%)
|
||||
|
||||
## ✅ Tasks
|
||||
6 pending
|
||||
• 5:00 PM - Dinner at Lecosho or Japonessa
|
||||
• 3:00 PM - Snack at Le Panier or Mee Sum
|
||||
• 3:15 PM - Seattle Art Museum (Impressionism Exhibit)
|
||||
• 2:30 PM - Route 7 Bus to Downtown
|
||||
• 2:00 PM - Coffee at QED (Mt Baker)
|
||||
... and 1 more
|
||||
|
||||
## 🖥 Infrastructure
|
||||
K8s: 🟡 | Workstation: 🟢
|
||||
└ K8s: 1 pods not running
|
||||
|
||||
## 📰 Tech News
|
||||
• Eat Real Food – Introducing the New Pyramid (Hacker News)
|
||||
• The $14 Burrito: Why San Francisco Inflation Feels Higher Than 2.5% (Hacker News)
|
||||
• Health care data breach affects over 600k patients, Illinois agency says (Hacker News)
|
||||
• Why the trans flag emoji is the 5-codepoint sequence it is (Lobsters)
|
||||
• A4 Paper Stories (Lobsters)
|
||||
|
||||
---
|
||||
*Generated: 2026-01-07 09:58:25 PT*
|
||||
@@ -0,0 +1,42 @@
|
||||
# Morning Report - Wed Jan 07, 2026
|
||||
|
||||
## 🌤 Weather
|
||||
Overcast 40°F (feels 33°F), 86% humidity, rain possible — bring umbrella
|
||||
|
||||
## 📧 Email
|
||||
10 unread, no urgent items
|
||||
|
||||
• Experian Alerts - Your FICO® Score went up. Nice work!
|
||||
• Experteer - 3 new opportunities for "AWS Architect"
|
||||
• Experian - December spending report is here
|
||||
• Chase - Freedom Unlimited balance is $538.97
|
||||
• Chase - Rewards balance has reached 0 POINTS
|
||||
|
||||
## 📅 Today
|
||||
No events today
|
||||
|
||||
## 📈 Stocks
|
||||
▲ CRWV $78.98 (+1.3%) | ▲ NVDA $189.74 (+1.3%) | ▲ MSFT $488.96 (+2.2%)
|
||||
|
||||
## ✅ Tasks
|
||||
6 pending
|
||||
• 5:00 PM - Dinner at Lecosho or Japonessa
|
||||
• 3:00 PM - Snack at Le Panier or Mee Sum
|
||||
• 3:15 PM - Seattle Art Museum (Impressionism Exhibit)
|
||||
• 2:30 PM - Route 7 Bus to Downtown
|
||||
• 2:00 PM - Coffee at QED (Mt Baker)
|
||||
... and 1 more
|
||||
|
||||
## 🖥 Infrastructure
|
||||
K8s: 🟡 | Workstation: 🟢
|
||||
└ K8s: 1 pods not running
|
||||
|
||||
## 📰 Tech News
|
||||
• Eat Real Food – Introducing the New Pyramid (Hacker News)
|
||||
• The $14 Burrito: Why San Francisco Inflation Feels Higher Than 2.5% (Hacker News)
|
||||
• Health care data breach affects over 600k patients, Illinois agency says (Hacker News)
|
||||
• Why the trans flag emoji is the 5-codepoint sequence it is (Lobsters)
|
||||
• A4 Paper Stories (Lobsters)
|
||||
|
||||
---
|
||||
*Generated: 2026-01-07 09:58:25 PT*
|
||||
+4
-2
@@ -9,8 +9,10 @@
|
||||
"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"
|
||||
"_note": "Agent definitions moved to ~/.claude/agents/*.md with YAML frontmatter. Autonomy levels now in ~/.claude/state/autonomy-levels.json",
|
||||
"model": "opus"
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ Agent skills that extend Claude's capabilities. Model-invoked (Claude decides wh
|
||||
| `sysadmin-health` | Arch Linux health check | `health-check.sh` |
|
||||
| `usage` | Session usage tracking | `usage_report.py` |
|
||||
| `programmer-add-project` | Register projects | (workflow only) |
|
||||
| `rag-search` | Semantic search (state + docs) | `search.py`, `index_personal.py`, `index_docs.py` |
|
||||
|
||||
## Skill Structure
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: gtasks
|
||||
description: Google Tasks read access — list pending tasks. Use when asked about tasks, todos, or what needs to be done.
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
---
|
||||
|
||||
# Google Tasks Skill
|
||||
|
||||
List pending Google Tasks. Uses OAuth credentials at `~/.gmail-mcp/`.
|
||||
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
GMAIL_PY=~/.claude/mcp/gmail/venv/bin/python
|
||||
SCRIPTS=~/.claude/skills/gtasks/scripts
|
||||
|
||||
# List tasks (default 10)
|
||||
$GMAIL_PY $SCRIPTS/list.py
|
||||
|
||||
# Show more tasks
|
||||
$GMAIL_PY $SCRIPTS/list.py 20
|
||||
```
|
||||
|
||||
## Script Reference
|
||||
|
||||
| Script | Purpose | Args |
|
||||
|--------|---------|------|
|
||||
| `list.py` | List pending tasks | `[max]` (default 10) |
|
||||
|
||||
## Request Routing
|
||||
|
||||
| User Request | Script |
|
||||
|--------------|--------|
|
||||
| "What are my tasks?" | `list.py` |
|
||||
| "Show my todos" | `list.py` |
|
||||
| "/tasks" | `list.py` |
|
||||
|
||||
## Output Format
|
||||
|
||||
```
|
||||
6 pending
|
||||
• 5:00 PM - Dinner at Lecosho
|
||||
• 3:00 PM - Snack at Le Panier
|
||||
• 2:00 PM - Coffee at QED
|
||||
```
|
||||
|
||||
## Policy
|
||||
|
||||
- **Read-only** operations only
|
||||
- **Summarize** results, don't dump raw data
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""List Google Tasks - thin wrapper around morning-report collector."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Import from morning-report collector
|
||||
sys.path.insert(0, str(Path.home() / ".claude/skills/morning-report/scripts/collectors"))
|
||||
|
||||
from gtasks import fetch_tasks, format_tasks
|
||||
|
||||
|
||||
def main():
|
||||
max_display = int(sys.argv[1]) if len(sys.argv) > 1 else 10
|
||||
tasks = fetch_tasks(max_results=max_display + 5)
|
||||
|
||||
if not tasks:
|
||||
print("No pending tasks.")
|
||||
return
|
||||
|
||||
# Check for error response
|
||||
if len(tasks) == 1 and "error" in tasks[0]:
|
||||
print(f"Error: {tasks[0]['error']}")
|
||||
return
|
||||
|
||||
print(format_tasks(tasks, max_display))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: morning-report
|
||||
description: Generate daily morning dashboard with email, calendar, stocks, weather, tasks, infrastructure status, and news
|
||||
---
|
||||
|
||||
# Morning Report Skill
|
||||
|
||||
Aggregates useful information into a single Markdown dashboard.
|
||||
|
||||
## Usage
|
||||
|
||||
Generate report:
|
||||
```bash
|
||||
~/.claude/skills/morning-report/scripts/generate.py
|
||||
```
|
||||
|
||||
Or use the `/morning` command.
|
||||
|
||||
## Output
|
||||
|
||||
- **Location:** `~/.claude/reports/morning.md`
|
||||
- **Archive:** `~/.claude/reports/archive/YYYY-MM-DD.md`
|
||||
|
||||
## Sections
|
||||
|
||||
| Section | Source | LLM Tier |
|
||||
|---------|--------|----------|
|
||||
| Weather | wttr.in | Haiku |
|
||||
| Email | Gmail API | Sonnet |
|
||||
| Calendar | Google Calendar API | None |
|
||||
| Stocks | Yahoo Finance | Haiku |
|
||||
| Tasks | Google Tasks API | None |
|
||||
| Infrastructure | k8s + sysadmin skills | Haiku |
|
||||
| News | RSS feeds | Sonnet |
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `~/.claude/skills/morning-report/config.json` to customize:
|
||||
- Stock watchlist
|
||||
- Weather location
|
||||
- RSS feeds
|
||||
- Display limits
|
||||
|
||||
## Scheduling
|
||||
|
||||
Systemd timer runs at 8:00 AM Pacific daily.
|
||||
|
||||
```bash
|
||||
# Check timer status
|
||||
systemctl --user status morning-report.timer
|
||||
|
||||
# View logs
|
||||
journalctl --user -u morning-report
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"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": {
|
||||
"enabled": true,
|
||||
"max_display": 5,
|
||||
"show_due_dates": true
|
||||
},
|
||||
"infra": {
|
||||
"check_k8s": true,
|
||||
"check_workstation": true,
|
||||
"detail_level": "traffic_light"
|
||||
},
|
||||
"news": {
|
||||
"feeds": [
|
||||
{"name": "Hacker News", "url": "https://hnrss.org/frontpage", "limit": 3},
|
||||
{"name": "Lobsters", "url": "https://lobste.rs/rss", "limit": 2}
|
||||
],
|
||||
"summarize": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# Morning report collectors
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
#!/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.14/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"])
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
#!/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.14/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"])
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
#!/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.14/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(force_reauth: bool = False):
|
||||
"""Get or refresh Google credentials for Tasks API.
|
||||
|
||||
If ``force_reauth`` is True, skip refresh and run a new OAuth flow.
|
||||
This is useful when a stored refresh token is bound to a different
|
||||
scope set and refresh keeps failing with invalid_scope.
|
||||
"""
|
||||
creds = None
|
||||
|
||||
if TOKEN_PATH.exists():
|
||||
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
|
||||
|
||||
if not creds or not creds.valid or force_reauth:
|
||||
if not force_reauth and creds and creds.expired and creds.refresh_token:
|
||||
try:
|
||||
creds.refresh(Request())
|
||||
except Exception:
|
||||
creds = None
|
||||
|
||||
if not creds or not creds.valid:
|
||||
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...")
|
||||
# Force a fresh OAuth flow so we can recover from invalid_scope
|
||||
# errors caused by stale refresh tokens.
|
||||
creds = get_credentials(force_reauth=True)
|
||||
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"])
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user