Compare commits

..

1 commit

Author SHA1 Message Date
36f790ed22 docs: add comprehensive CLI documentation for issue #4
Added comprehensive documentation for core/cli including:

1. Complete CLI Reference (cli-reference.md)
   - All 30 commands with detailed descriptions
   - Subcommands, flags, and usage examples
   - Organized by category (Development, AI/ML, DevOps, etc.)

2. Build Variants System (build-variants.md)
   - How to create custom builds (minimal, dev, devops, ai/ml)
   - Three methods: import commenting, multiple main.go, build tags
   - External variant repos (core/php, core/ci)
   - Size optimization and compression tips

3. MCP Integration (mcp-integration.md)
   - Model Context Protocol server setup
   - All available tools (file ops, RAG, metrics, process mgmt, WebView/CDP)
   - Transport modes (stdio, TCP)
   - Security considerations and workspace restriction
   - Use cases and troubleshooting

4. Environment Variables (environment-variables.md)
   - All supported environment variables
   - Configuration file locations (user, project, registry)
   - CORE_CONFIG_* mapping system
   - Service-specific vars (MCP, RAG, ML, Git, etc.)
   - Best practices and troubleshooting

5. Updated Navigation
   - Enhanced cmd/index.md with categories and quick links
   - Updated mkdocs.yml with new documentation pages

Fixes #4

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-16 17:16:48 +00:00
394 changed files with 68850 additions and 495 deletions

5
.claude/settings.json Normal file
View file

@ -0,0 +1,5 @@
{
"enabledPlugins": {
"core@core-claude": true
}
}

121
.core/linuxkit/core-dev.yml Normal file
View file

@ -0,0 +1,121 @@
# Core Development Environment Template
# A full-featured development environment with multiple runtimes
#
# Variables:
# ${SSH_KEY} - SSH public key for access (required)
# ${MEMORY:-2048} - Memory in MB (default: 2048)
# ${CPUS:-2} - Number of CPUs (default: 2)
# ${HOSTNAME:-core-dev} - Hostname for the VM
# ${DATA_SIZE:-10G} - Size of persistent /data volume
kernel:
image: linuxkit/kernel:6.6.13
cmdline: "console=tty0 console=ttyS0"
init:
- linuxkit/init:v1.2.0
- linuxkit/runc:v1.1.12
- linuxkit/containerd:v1.7.13
- linuxkit/ca-certificates:v1.0.0
onboot:
- name: sysctl
image: linuxkit/sysctl:v1.0.0
- name: format
image: linuxkit/format:v1.0.0
- name: mount
image: linuxkit/mount:v1.0.0
command: ["/usr/bin/mountie", "/dev/sda1", "/data"]
- name: dhcpcd
image: linuxkit/dhcpcd:v1.0.0
command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf", "-1"]
onshutdown:
- name: shutdown
image: busybox:latest
command: ["/bin/echo", "Shutting down..."]
services:
- name: getty
image: linuxkit/getty:v1.0.0
env:
- INSECURE=true
- name: sshd
image: linuxkit/sshd:v1.2.0
binds:
- /etc/ssh/authorized_keys:/root/.ssh/authorized_keys
- name: docker
image: docker:24.0-dind
capabilities:
- all
net: host
pid: host
binds:
- /var/run:/var/run
- /data/docker:/var/lib/docker
rootfsPropagation: shared
- name: dev-tools
image: alpine:3.19
capabilities:
- all
net: host
binds:
- /data:/data
command:
- /bin/sh
- -c
- |
# Install development tools
apk add --no-cache \
git curl wget vim nano htop tmux \
build-base gcc musl-dev linux-headers \
openssh-client jq yq
# Install Go 1.22.0
wget -q https://go.dev/dl/go1.22.0.linux-amd64.tar.gz
tar -C /usr/local -xzf go1.22.0.linux-amd64.tar.gz
rm go1.22.0.linux-amd64.tar.gz
echo 'export PATH=/usr/local/go/bin:$PATH' >> /etc/profile
# Install Node.js
apk add --no-cache nodejs npm
# Install PHP
apk add --no-cache php82 php82-cli php82-curl php82-json php82-mbstring \
php82-openssl php82-pdo php82-pdo_mysql php82-pdo_pgsql php82-phar \
php82-session php82-tokenizer php82-xml php82-zip composer
# Keep container running
tail -f /dev/null
files:
- path: /etc/hostname
contents: "${HOSTNAME:-core-dev}"
- path: /etc/ssh/authorized_keys
contents: "${SSH_KEY}"
mode: "0600"
- path: /etc/profile.d/dev.sh
contents: |
export PATH=$PATH:/usr/local/go/bin
export GOPATH=/data/go
export PATH=$PATH:$GOPATH/bin
cd /data
mode: "0755"
- path: /etc/motd
contents: |
================================================
Core Development Environment
Runtimes: Go, Node.js, PHP
Tools: git, curl, vim, docker
Data directory: /data (persistent)
================================================
trust:
org:
- linuxkit
- library

View file

@ -0,0 +1,142 @@
# PHP/FrankenPHP Server Template
# A minimal production-ready PHP server with FrankenPHP and Caddy
#
# Variables:
# ${SSH_KEY} - SSH public key for management access (required)
# ${MEMORY:-512} - Memory in MB (default: 512)
# ${CPUS:-1} - Number of CPUs (default: 1)
# ${HOSTNAME:-php-server} - Hostname for the VM
# ${APP_NAME:-app} - Application name
# ${DOMAIN:-localhost} - Domain for SSL certificates
# ${PHP_MEMORY:-128M} - PHP memory limit
kernel:
image: linuxkit/kernel:6.6.13
cmdline: "console=tty0 console=ttyS0"
init:
- linuxkit/init:v1.2.0
- linuxkit/runc:v1.1.12
- linuxkit/containerd:v1.7.13
- linuxkit/ca-certificates:v1.0.0
onboot:
- name: sysctl
image: linuxkit/sysctl:v1.0.0
- name: dhcpcd
image: linuxkit/dhcpcd:v1.0.0
command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf", "-1"]
services:
- name: sshd
image: linuxkit/sshd:v1.2.0
binds:
- /etc/ssh/authorized_keys:/root/.ssh/authorized_keys
- name: frankenphp
image: dunglas/frankenphp:latest
capabilities:
- CAP_NET_BIND_SERVICE
net: host
binds:
- /app:/app
- /data:/data
- /etc/caddy/Caddyfile:/etc/caddy/Caddyfile
env:
- SERVER_NAME=${DOMAIN:-localhost}
- FRANKENPHP_CONFIG=/etc/caddy/Caddyfile
command:
- frankenphp
- run
- --config
- /etc/caddy/Caddyfile
- name: healthcheck
image: alpine:3.19
net: host
command:
- /bin/sh
- -c
- |
apk add --no-cache curl
while true; do
sleep 30
curl -sf http://localhost/health || echo "Health check failed"
done
files:
- path: /etc/hostname
contents: "${HOSTNAME:-php-server}"
- path: /etc/ssh/authorized_keys
contents: "${SSH_KEY}"
mode: "0600"
- path: /etc/caddy/Caddyfile
contents: |
{
frankenphp
order php_server before file_server
}
${DOMAIN:-localhost} {
root * /app/public
# Health check endpoint
handle /health {
respond "OK" 200
}
# PHP handling
php_server
# Encode responses
encode zstd gzip
# Security headers
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
X-XSS-Protection "1; mode=block"
Referrer-Policy strict-origin-when-cross-origin
}
# Logging
log {
output file /data/logs/access.log
format json
}
}
mode: "0644"
- path: /app/public/index.php
contents: |
<?php
echo "Welcome to ${APP_NAME:-app}";
mode: "0644"
- path: /app/public/health.php
contents: |
<?php
header('Content-Type: application/json');
echo json_encode([
'status' => 'healthy',
'app' => '${APP_NAME:-app}',
'timestamp' => date('c'),
'php_version' => PHP_VERSION,
]);
mode: "0644"
- path: /etc/php/php.ini
contents: |
memory_limit = ${PHP_MEMORY:-128M}
max_execution_time = 30
upload_max_filesize = 64M
post_max_size = 64M
display_errors = Off
log_errors = On
error_log = /data/logs/php_errors.log
mode: "0644"
- path: /data/logs/.gitkeep
contents: ""
trust:
org:
- linuxkit
- library
- dunglas

View file

@ -0,0 +1,36 @@
---
name: remember
description: Save a fact or decision to context for persistence across compacts
args: <fact to remember>
---
# Remember Context
Save the provided fact to `~/.claude/sessions/context.json`.
## Usage
```
/core:remember Use Action pattern not Service
/core:remember User prefers UK English
/core:remember RFC: minimal state in pre-compact hook
```
## Action
Run this command to save the fact:
```bash
~/.claude/plugins/cache/core/scripts/capture-context.sh "<fact>" "user"
```
Or if running from the plugin directory:
```bash
"${CLAUDE_PLUGIN_ROOT}/scripts/capture-context.sh" "<fact>" "user"
```
The fact will be:
- Stored in context.json (max 20 items)
- Included in pre-compact snapshots
- Auto-cleared after 3 hours of inactivity

102
.core/plugin/hooks/prefer-core.sh Executable file
View file

@ -0,0 +1,102 @@
#!/bin/bash
# PreToolUse hook: Block dangerous commands, enforce core CLI
#
# BLOCKS:
# - Raw go commands (use core go *)
# - Destructive grep patterns (sed -i, xargs rm, etc.)
# - Mass file operations (rm -rf, mv/cp with wildcards)
# - Any sed outside of safe patterns
#
# This prevents "efficient shortcuts" that nuke codebases
read -r input
command=$(echo "$input" | jq -r '.tool_input.command // empty')
# === HARD BLOCKS - Never allow these ===
# Block rm -rf, rm -r (except for known safe paths like node_modules, vendor, .cache)
if echo "$command" | grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*r|--recursive)'; then
# Allow only specific safe directories
if ! echo "$command" | grep -qE 'rm\s+(-rf|-r)\s+(node_modules|vendor|\.cache|dist|build|__pycache__|\.pytest_cache|/tmp/)'; then
echo '{"decision": "block", "message": "BLOCKED: Recursive delete is not allowed. Delete files individually or ask the user to run this command."}'
exit 0
fi
fi
# Block mv/cp with wildcards (mass file moves)
if echo "$command" | grep -qE '(mv|cp)\s+.*\*'; then
echo '{"decision": "block", "message": "BLOCKED: Mass file move/copy with wildcards is not allowed. Move files individually."}'
exit 0
fi
# Block xargs with rm, mv, cp (mass operations)
if echo "$command" | grep -qE 'xargs\s+.*(rm|mv|cp)'; then
echo '{"decision": "block", "message": "BLOCKED: xargs with file operations is not allowed. Too risky for mass changes."}'
exit 0
fi
# Block find -exec with rm, mv, cp
if echo "$command" | grep -qE 'find\s+.*-exec\s+.*(rm|mv|cp)'; then
echo '{"decision": "block", "message": "BLOCKED: find -exec with file operations is not allowed. Too risky for mass changes."}'
exit 0
fi
# Block ALL sed -i (in-place editing)
if echo "$command" | grep -qE 'sed\s+(-[a-zA-Z]*i|--in-place)'; then
echo '{"decision": "block", "message": "BLOCKED: sed -i (in-place edit) is never allowed. Use the Edit tool for file changes."}'
exit 0
fi
# Block sed piped to file operations
if echo "$command" | grep -qE 'sed.*\|.*tee|sed.*>'; then
echo '{"decision": "block", "message": "BLOCKED: sed with file output is not allowed. Use the Edit tool for file changes."}'
exit 0
fi
# Block grep with -l piped to xargs/rm/sed (the classic codebase nuke pattern)
if echo "$command" | grep -qE 'grep\s+.*-l.*\|'; then
echo '{"decision": "block", "message": "BLOCKED: grep -l piped to other commands is the classic codebase nuke pattern. Not allowed."}'
exit 0
fi
# Block perl -i, awk with file redirection (sed alternatives)
if echo "$command" | grep -qE 'perl\s+-[a-zA-Z]*i|awk.*>'; then
echo '{"decision": "block", "message": "BLOCKED: In-place file editing with perl/awk is not allowed. Use the Edit tool."}'
exit 0
fi
# === REQUIRE CORE CLI ===
# Block raw go commands
case "$command" in
"go test"*|"go build"*|"go fmt"*|"go mod tidy"*|"go vet"*|"go run"*)
echo '{"decision": "block", "message": "Use `core go test`, `core build`, `core go fmt --fix`, etc. Raw go commands are not allowed."}'
exit 0
;;
"go "*)
# Other go commands - warn but allow
echo '{"decision": "block", "message": "Prefer `core go *` commands. If core does not have this command, ask the user."}'
exit 0
;;
esac
# Block raw php commands
case "$command" in
"php artisan serve"*|"./vendor/bin/pest"*|"./vendor/bin/pint"*|"./vendor/bin/phpstan"*)
echo '{"decision": "block", "message": "Use `core php dev`, `core php test`, `core php fmt`, `core php analyse`. Raw php commands are not allowed."}'
exit 0
;;
"composer test"*|"composer lint"*)
echo '{"decision": "block", "message": "Use `core php test` or `core php fmt`. Raw composer commands are not allowed."}'
exit 0
;;
esac
# Block golangci-lint directly
if echo "$command" | grep -qE '^golangci-lint'; then
echo '{"decision": "block", "message": "Use `core go lint` instead of golangci-lint directly."}'
exit 0
fi
# === APPROVED ===
echo '{"decision": "approve"}'

102
.core/plugin/plugin.json Normal file
View file

@ -0,0 +1,102 @@
{
"name": "core",
"version": "1.0.0",
"description": "Host UK unified framework - Go CLI, PHP framework, multi-repo management",
"dependencies": [
"superpowers@claude-plugins-official"
],
"skills": [
{
"name": "core",
"path": "skills/core.md",
"description": "Use when working in host-uk repositories. Provides core CLI command reference."
},
{
"name": "core-php",
"path": "skills/php.md",
"description": "Use when creating PHP modules, services, or actions in core-* packages."
},
{
"name": "core-go",
"path": "skills/go.md",
"description": "Use when creating Go packages or extending the core CLI."
}
],
"commands": [
{
"name": "remember",
"path": "commands/remember.md",
"description": "Save a fact or decision to context"
}
],
"hooks": {
"SessionStart": [
{
"matcher": "*",
"script": "scripts/session-start.sh",
"description": "Check for recent session state on startup"
}
],
"PreCompact": [
{
"matcher": "*",
"script": "scripts/pre-compact.sh",
"description": "Save state before auto-compact to prevent amnesia"
}
],
"PreToolUse": [
{
"matcher": "Bash",
"script": "hooks/prefer-core.sh",
"description": "Suggest core CLI instead of raw go/php commands"
},
{
"matcher": "Write",
"script": "scripts/block-docs.sh",
"description": "Block random .md files, keep docs consolidated"
},
{
"matcher": "Edit",
"script": "scripts/suggest-compact.sh",
"description": "Suggest /compact at logical intervals"
},
{
"matcher": "Write",
"script": "scripts/suggest-compact.sh",
"description": "Suggest /compact at logical intervals"
}
],
"PostToolUse": [
{
"matcher": "Edit",
"script": "scripts/php-format.sh",
"description": "Auto-format PHP files after edits"
},
{
"matcher": "Edit",
"script": "scripts/go-format.sh",
"description": "Auto-format Go files after edits"
},
{
"matcher": "Edit",
"script": "scripts/check-debug.sh",
"description": "Warn about debug statements (dd, dump, fmt.Println)"
},
{
"matcher": "Bash",
"script": "scripts/pr-created.sh",
"description": "Log PR URL after creation"
},
{
"matcher": "Bash",
"script": "scripts/extract-actionables.sh",
"description": "Extract actionables from core CLI output"
},
{
"matcher": "Bash",
"script": "scripts/post-commit-check.sh",
"description": "Warn about uncommitted work after git commit"
}
]
}
}

View file

@ -0,0 +1,27 @@
#!/bin/bash
# Block creation of random .md files - keeps docs consolidated
read -r input
FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty')
if [[ -n "$FILE_PATH" ]]; then
# Allow known documentation files
case "$FILE_PATH" in
*README.md|*CLAUDE.md|*AGENTS.md|*CONTRIBUTING.md|*CHANGELOG.md|*LICENSE.md)
echo "$input"
exit 0
;;
# Allow docs/ directory
*/docs/*.md|*/docs/**/*.md)
echo "$input"
exit 0
;;
# Block other .md files
*.md)
echo '{"decision": "block", "message": "Use README.md or docs/ for documentation. Random .md files clutter the repo."}'
exit 0
;;
esac
fi
echo "$input"

View file

@ -0,0 +1,44 @@
#!/bin/bash
# Capture context facts from tool output or conversation
# Called by PostToolUse hooks to extract actionable items
#
# Stores in ~/.claude/sessions/context.json as:
# [{"fact": "...", "source": "core go qa", "ts": 1234567890}, ...]
CONTEXT_FILE="${HOME}/.claude/sessions/context.json"
TIMESTAMP=$(date '+%s')
THREE_HOURS=10800
mkdir -p "${HOME}/.claude/sessions"
# Initialize if missing or stale
if [[ -f "$CONTEXT_FILE" ]]; then
FIRST_TS=$(jq -r '.[0].ts // 0' "$CONTEXT_FILE" 2>/dev/null)
NOW=$(date '+%s')
AGE=$((NOW - FIRST_TS))
if [[ $AGE -gt $THREE_HOURS ]]; then
echo "[]" > "$CONTEXT_FILE"
fi
else
echo "[]" > "$CONTEXT_FILE"
fi
# Read input (fact and source passed as args or stdin)
FACT="${1:-}"
SOURCE="${2:-manual}"
if [[ -z "$FACT" ]]; then
# Try reading from stdin
read -r FACT
fi
if [[ -n "$FACT" ]]; then
# Append to context (keep last 20 items)
jq --arg fact "$FACT" --arg source "$SOURCE" --argjson ts "$TIMESTAMP" \
'. + [{"fact": $fact, "source": $source, "ts": $ts}] | .[-20:]' \
"$CONTEXT_FILE" > "${CONTEXT_FILE}.tmp" && mv "${CONTEXT_FILE}.tmp" "$CONTEXT_FILE"
echo "[Context] Saved: $FACT" >&2
fi
exit 0

View file

@ -0,0 +1,27 @@
#!/bin/bash
# Warn about debug statements left in code after edits
read -r input
FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty')
if [[ -n "$FILE_PATH" && -f "$FILE_PATH" ]]; then
case "$FILE_PATH" in
*.go)
# Check for fmt.Println, log.Println debug statements
if grep -n "fmt\.Println\|log\.Println" "$FILE_PATH" 2>/dev/null | head -3 | grep -q .; then
echo "[Hook] WARNING: Debug prints found in $FILE_PATH" >&2
grep -n "fmt\.Println\|log\.Println" "$FILE_PATH" 2>/dev/null | head -3 >&2
fi
;;
*.php)
# Check for dd(), dump(), var_dump(), print_r()
if grep -n "dd(\|dump(\|var_dump(\|print_r(" "$FILE_PATH" 2>/dev/null | head -3 | grep -q .; then
echo "[Hook] WARNING: Debug statements found in $FILE_PATH" >&2
grep -n "dd(\|dump(\|var_dump(\|print_r(" "$FILE_PATH" 2>/dev/null | head -3 >&2
fi
;;
esac
fi
# Pass through the input
echo "$input"

View file

@ -0,0 +1,34 @@
#!/bin/bash
# Extract actionable items from core CLI output
# Called PostToolUse on Bash commands that run core
read -r input
COMMAND=$(echo "$input" | jq -r '.tool_input.command // empty')
OUTPUT=$(echo "$input" | jq -r '.tool_output.output // empty')
CONTEXT_SCRIPT="$(dirname "$0")/capture-context.sh"
# Extract actionables from specific core commands
case "$COMMAND" in
"core go qa"*|"core go test"*|"core go lint"*)
# Extract error/warning lines
echo "$OUTPUT" | grep -E "^(ERROR|WARN|FAIL|---)" | head -5 | while read -r line; do
"$CONTEXT_SCRIPT" "$line" "core go"
done
;;
"core php test"*|"core php analyse"*)
# Extract PHP errors
echo "$OUTPUT" | grep -E "^(FAIL|Error|×)" | head -5 | while read -r line; do
"$CONTEXT_SCRIPT" "$line" "core php"
done
;;
"core build"*)
# Extract build errors
echo "$OUTPUT" | grep -E "^(error|cannot|undefined)" | head -5 | while read -r line; do
"$CONTEXT_SCRIPT" "$line" "core build"
done
;;
esac
# Pass through
echo "$input"

View file

@ -0,0 +1,19 @@
#!/bin/bash
# Auto-format Go files after edits using core go fmt
read -r input
FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty')
if [[ -n "$FILE_PATH" && -f "$FILE_PATH" ]]; then
# Run gofmt/goimports on the file silently
if command -v core &> /dev/null; then
core go fmt --fix "$FILE_PATH" 2>/dev/null || true
elif command -v goimports &> /dev/null; then
goimports -w "$FILE_PATH" 2>/dev/null || true
elif command -v gofmt &> /dev/null; then
gofmt -w "$FILE_PATH" 2>/dev/null || true
fi
fi
# Pass through the input
echo "$input"

View file

@ -0,0 +1,17 @@
#!/bin/bash
# Auto-format PHP files after edits using core php fmt
read -r input
FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty')
if [[ -n "$FILE_PATH" && -f "$FILE_PATH" ]]; then
# Run Pint on the file silently
if command -v core &> /dev/null; then
core php fmt --fix "$FILE_PATH" 2>/dev/null || true
elif [[ -f "./vendor/bin/pint" ]]; then
./vendor/bin/pint "$FILE_PATH" 2>/dev/null || true
fi
fi
# Pass through the input
echo "$input"

View file

@ -0,0 +1,51 @@
#!/bin/bash
# Post-commit hook: Check for uncommitted work that might get lost
#
# After committing task-specific files, check if there's other work
# in the repo that should be committed or stashed
read -r input
COMMAND=$(echo "$input" | jq -r '.tool_input.command // empty')
# Only run after git commit
if ! echo "$COMMAND" | grep -qE '^git commit'; then
echo "$input"
exit 0
fi
# Check for remaining uncommitted changes
UNSTAGED=$(git diff --name-only 2>/dev/null | wc -l | tr -d ' ')
STAGED=$(git diff --cached --name-only 2>/dev/null | wc -l | tr -d ' ')
UNTRACKED=$(git ls-files --others --exclude-standard 2>/dev/null | wc -l | tr -d ' ')
TOTAL=$((UNSTAGED + STAGED + UNTRACKED))
if [[ $TOTAL -gt 0 ]]; then
echo "" >&2
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2
echo "[PostCommit] WARNING: Uncommitted work remains" >&2
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2
if [[ $UNSTAGED -gt 0 ]]; then
echo " Modified (unstaged): $UNSTAGED files" >&2
git diff --name-only 2>/dev/null | head -5 | sed 's/^/ /' >&2
[[ $UNSTAGED -gt 5 ]] && echo " ... and $((UNSTAGED - 5)) more" >&2
fi
if [[ $STAGED -gt 0 ]]; then
echo " Staged (not committed): $STAGED files" >&2
git diff --cached --name-only 2>/dev/null | head -5 | sed 's/^/ /' >&2
fi
if [[ $UNTRACKED -gt 0 ]]; then
echo " Untracked: $UNTRACKED files" >&2
git ls-files --others --exclude-standard 2>/dev/null | head -5 | sed 's/^/ /' >&2
[[ $UNTRACKED -gt 5 ]] && echo " ... and $((UNTRACKED - 5)) more" >&2
fi
echo "" >&2
echo "Consider: commit these, stash them, or confirm they're intentionally left" >&2
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2
fi
echo "$input"

View file

@ -0,0 +1,18 @@
#!/bin/bash
# Log PR URL and provide review command after PR creation
read -r input
COMMAND=$(echo "$input" | jq -r '.tool_input.command // empty')
OUTPUT=$(echo "$input" | jq -r '.tool_output.output // empty')
if [[ "$COMMAND" == *"gh pr create"* ]]; then
PR_URL=$(echo "$OUTPUT" | grep -oE 'https://github.com/[^/]+/[^/]+/pull/[0-9]+' | head -1)
if [[ -n "$PR_URL" ]]; then
REPO=$(echo "$PR_URL" | sed -E 's|https://github.com/([^/]+/[^/]+)/pull/[0-9]+|\1|')
PR_NUM=$(echo "$PR_URL" | sed -E 's|.*/pull/([0-9]+)|\1|')
echo "[Hook] PR created: $PR_URL" >&2
echo "[Hook] To review: gh pr review $PR_NUM --repo $REPO" >&2
fi
fi
echo "$input"

View file

@ -0,0 +1,69 @@
#!/bin/bash
# Pre-compact: Save minimal state for Claude to resume after auto-compact
#
# Captures:
# - Working directory + branch
# - Git status (files touched)
# - Todo state (in_progress items)
# - Context facts (decisions, actionables)
STATE_FILE="${HOME}/.claude/sessions/scratchpad.md"
CONTEXT_FILE="${HOME}/.claude/sessions/context.json"
TIMESTAMP=$(date '+%s')
CWD=$(pwd)
mkdir -p "${HOME}/.claude/sessions"
# Get todo state
TODOS=""
if [[ -f "${HOME}/.claude/todos/current.json" ]]; then
TODOS=$(cat "${HOME}/.claude/todos/current.json" 2>/dev/null | head -50)
fi
# Get git status
GIT_STATUS=""
BRANCH=""
if git rev-parse --git-dir > /dev/null 2>&1; then
GIT_STATUS=$(git status --short 2>/dev/null | head -15)
BRANCH=$(git branch --show-current 2>/dev/null)
fi
# Get context facts
CONTEXT=""
if [[ -f "$CONTEXT_FILE" ]]; then
CONTEXT=$(jq -r '.[] | "- [\(.source)] \(.fact)"' "$CONTEXT_FILE" 2>/dev/null | tail -10)
fi
cat > "$STATE_FILE" << EOF
---
timestamp: ${TIMESTAMP}
cwd: ${CWD}
branch: ${BRANCH:-none}
---
# Resume After Compact
You were mid-task. Do NOT assume work is complete.
## Project
\`${CWD}\` on \`${BRANCH:-no branch}\`
## Files Changed
\`\`\`
${GIT_STATUS:-none}
\`\`\`
## Todos (in_progress = NOT done)
\`\`\`json
${TODOS:-check /todos}
\`\`\`
## Context (decisions & actionables)
${CONTEXT:-none captured}
## Next
Continue the in_progress todo.
EOF
echo "[PreCompact] Snapshot saved" >&2
exit 0

View file

@ -0,0 +1,34 @@
#!/bin/bash
# Session start: Read scratchpad if recent, otherwise start fresh
# 3 hour window - if older, you've moved on mentally
STATE_FILE="${HOME}/.claude/sessions/scratchpad.md"
THREE_HOURS=10800 # seconds
if [[ -f "$STATE_FILE" ]]; then
# Get timestamp from file
FILE_TS=$(grep -E '^timestamp:' "$STATE_FILE" 2>/dev/null | cut -d' ' -f2)
NOW=$(date '+%s')
if [[ -n "$FILE_TS" ]]; then
AGE=$((NOW - FILE_TS))
if [[ $AGE -lt $THREE_HOURS ]]; then
# Recent - read it back
echo "[SessionStart] Found recent scratchpad ($(($AGE / 60)) min ago)" >&2
echo "[SessionStart] Reading previous state..." >&2
echo "" >&2
cat "$STATE_FILE" >&2
echo "" >&2
else
# Stale - delete and start fresh
rm -f "$STATE_FILE"
echo "[SessionStart] Previous session >3h old - starting fresh" >&2
fi
else
# No timestamp, delete it
rm -f "$STATE_FILE"
fi
fi
exit 0

View file

@ -0,0 +1,28 @@
#!/bin/bash
# Suggest /compact at logical intervals to manage context window
# Tracks tool calls per session, suggests compaction every 50 calls
SESSION_ID="${CLAUDE_SESSION_ID:-$$}"
COUNTER_FILE="/tmp/claude-tool-count-${SESSION_ID}"
THRESHOLD="${COMPACT_THRESHOLD:-50}"
# Read or initialize counter
if [[ -f "$COUNTER_FILE" ]]; then
COUNT=$(($(cat "$COUNTER_FILE") + 1))
else
COUNT=1
fi
echo "$COUNT" > "$COUNTER_FILE"
# Suggest compact at threshold
if [[ $COUNT -eq $THRESHOLD ]]; then
echo "[Compact] ${THRESHOLD} tool calls - consider /compact if transitioning phases" >&2
fi
# Suggest at intervals after threshold
if [[ $COUNT -gt $THRESHOLD ]] && [[ $((COUNT % 25)) -eq 0 ]]; then
echo "[Compact] ${COUNT} tool calls - good checkpoint for /compact" >&2
fi
exit 0

View file

@ -0,0 +1,60 @@
---
name: core
description: Use when working in host-uk repositories, running tests, building, releasing, or managing multi-repo workflows. Provides the core CLI command reference.
---
# Core CLI
The `core` command provides a unified interface for Go/PHP development and multi-repo management.
**Rule:** Always prefer `core <command>` over raw commands.
## Quick Reference
| Task | Command |
|------|---------|
| Go tests | `core go test` |
| Go coverage | `core go cov` |
| Go format | `core go fmt --fix` |
| Go lint | `core go lint` |
| PHP dev server | `core php dev` |
| PHP tests | `core php test` |
| PHP format | `core php fmt --fix` |
| Build | `core build` |
| Preview release | `core ci` |
| Publish | `core ci --were-go-for-launch` |
| Multi-repo status | `core dev health` |
| Commit dirty repos | `core dev commit` |
| Push repos | `core dev push` |
## Decision Tree
```
Go project?
tests: core go test
format: core go fmt --fix
build: core build
PHP project?
dev: core php dev
tests: core php test
format: core php fmt --fix
deploy: core php deploy
Multiple repos?
status: core dev health
commit: core dev commit
push: core dev push
```
## Common Mistakes
| Wrong | Right |
|-------|-------|
| `go test ./...` | `core go test` |
| `go build` | `core build` |
| `php artisan serve` | `core php dev` |
| `./vendor/bin/pest` | `core php test` |
| `git status` per repo | `core dev health` |
Run `core --help` or `core <cmd> --help` for full options.

107
.core/plugin/skills/go.md Normal file
View file

@ -0,0 +1,107 @@
---
name: core-go
description: Use when creating Go packages or extending the core CLI.
---
# Go Framework Patterns
Core CLI uses `pkg/` for reusable packages. Use `core go` commands.
## Package Structure
```
core/
├── main.go # CLI entry point
├── pkg/
│ ├── cli/ # CLI framework, output, errors
│ ├── {domain}/ # Domain package
│ │ ├── cmd_{name}.go # Cobra command definitions
│ │ ├── service.go # Business logic
│ │ └── *_test.go # Tests
│ └── ...
└── internal/ # Private packages
```
## Adding a CLI Command
1. Create `pkg/{domain}/cmd_{name}.go`:
```go
package domain
import (
"forge.lthn.ai/core/cli/pkg/cli"
"github.com/spf13/cobra"
)
func NewNameCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "name",
Short: cli.T("domain.name.short"),
RunE: func(cmd *cobra.Command, args []string) error {
// Implementation
cli.Success("Done")
return nil
},
}
return cmd
}
```
2. Register in parent command.
## CLI Output Helpers
```go
import "forge.lthn.ai/core/cli/pkg/cli"
cli.Success("Operation completed") // Green check
cli.Warning("Something to note") // Yellow warning
cli.Error("Something failed") // Red error
cli.Info("Informational message") // Blue info
cli.Fatal(err) // Print error and exit 1
// Structured output
cli.Table(headers, rows)
cli.JSON(data)
```
## i18n Pattern
```go
// Use cli.T() for translatable strings
cli.T("domain.action.success")
cli.T("domain.action.error", "details", value)
// Define in pkg/i18n/locales/en.yaml:
domain:
action:
success: "Operation completed successfully"
error: "Failed: {{.details}}"
```
## Test Naming
```go
func TestFeature_Good(t *testing.T) { /* happy path */ }
func TestFeature_Bad(t *testing.T) { /* expected errors */ }
func TestFeature_Ugly(t *testing.T) { /* panics, edge cases */ }
```
## Commands
| Task | Command |
|------|---------|
| Run tests | `core go test` |
| Coverage | `core go cov` |
| Format | `core go fmt --fix` |
| Lint | `core go lint` |
| Build | `core build` |
| Install | `core go install` |
## Rules
- `CGO_ENABLED=0` for all builds
- UK English in user-facing strings
- All errors via `cli.E("context", "message", err)`
- Table-driven tests preferred

120
.core/plugin/skills/php.md Normal file
View file

@ -0,0 +1,120 @@
---
name: core-php
description: Use when creating PHP modules, services, or actions in core-* packages.
---
# PHP Framework Patterns
Host UK PHP modules follow strict conventions. Use `core php` commands.
## Module Structure
```
core-{name}/
├── src/
│ ├── Core/ # Namespace: Core\{Name}
│ │ ├── Boot.php # Module bootstrap (listens to lifecycle events)
│ │ ├── Actions/ # Single-purpose business logic
│ │ └── Models/ # Eloquent models
│ └── Mod/ # Namespace: Core\Mod\{Name} (optional extensions)
├── resources/views/ # Blade templates
├── routes/ # Route definitions
├── database/migrations/ # Migrations
├── tests/ # Pest tests
└── composer.json
```
## Boot Class Pattern
```php
<?php
declare(strict_types=1);
namespace Core\{Name};
use Core\Php\Events\WebRoutesRegistering;
use Core\Php\Events\AdminPanelBooting;
class Boot
{
public static array $listens = [
WebRoutesRegistering::class => 'onWebRoutes',
AdminPanelBooting::class => ['onAdmin', 10], // With priority
];
public function onWebRoutes(WebRoutesRegistering $event): void
{
$event->router->middleware('web')->group(__DIR__ . '/../routes/web.php');
}
public function onAdmin(AdminPanelBooting $event): void
{
$event->panel->resources([...]);
}
}
```
## Action Pattern
```php
<?php
declare(strict_types=1);
namespace Core\{Name}\Actions;
use Core\Php\Action;
class CreateThing
{
use Action;
public function handle(User $user, array $data): Thing
{
return Thing::create([
'user_id' => $user->id,
...$data,
]);
}
}
// Usage: CreateThing::run($user, $validated);
```
## Multi-Tenant Models
```php
<?php
declare(strict_types=1);
namespace Core\{Name}\Models;
use Core\Tenant\Concerns\BelongsToWorkspace;
use Illuminate\Database\Eloquent\Model;
class Thing extends Model
{
use BelongsToWorkspace; // Auto-scopes queries, sets workspace_id
protected $fillable = ['name', 'workspace_id'];
}
```
## Commands
| Task | Command |
|------|---------|
| Run tests | `core php test` |
| Format | `core php fmt --fix` |
| Analyse | `core php analyse` |
| Dev server | `core php dev` |
## Rules
- Always `declare(strict_types=1);`
- UK English: colour, organisation, centre
- Type hints on all parameters and returns
- Pest for tests, not PHPUnit
- Flux Pro for UI, not vanilla Alpine

View file

@ -0,0 +1,50 @@
# Implementation Plan: Issue 258
## Phase 1: Command Structure
1. Extend existing `internal/cmd/test/cmd_main.go` with smart detection flags
2. Add flags: `--all`, `--filter` (alias for `--run`)
3. Existing flags (`--coverage`, `--verbose`, `--short`, `--race`, `--json`, `--pkg`, `--run`) are already registered
## Phase 2: Change Detection
1. Determine diff strategy based on context:
- **Local development** (default): `git diff --name-only HEAD` for uncommitted changes, plus `git diff --name-only --cached` for staged changes
- **CI/PR context**: `git diff --name-only origin/dev...HEAD` to compare against base branch
- Auto-detect CI via `CI` or `GITHUB_ACTIONS` env vars; allow override via `--base` flag
2. Filter for `.go` files (exclude `_test.go`)
3. Use `git diff --name-status` to detect renames (R), adds (A), and deletes (D):
- **Renames**: Map tests to the new file path
- **Deletes**: Skip deleted source files (do not run orphaned tests)
- **New files without tests**: Log a warning
4. Map each changed file to test file(s) using N:M discovery:
- Search for `*_test.go` files in the same package directory (not just `<file>_test.go`)
- Handle shared test files that cover multiple source files
- `internal/foo/bar.go``internal/foo/bar_test.go`, `internal/foo/bar_integration_test.go`, etc.
- Skip if no matching test files exist (warn user)
## Phase 3: Test Execution
1. Reuse existing `runTest()` from `internal/cmd/test/cmd_runner.go`
- This preserves environment setup (`MACOSX_DEPLOYMENT_TARGET`), output filtering (linker warnings), coverage parsing, JSON support, and consistent styling
2. Map smart detection flags to existing `runTest()` parameters:
- `--coverage``coverage` param (already exists)
- `--filter``run` param (mapped to `-run`)
- Detected test packages → `pkg` param (comma-joined or iterated)
3. Do not invoke `go test` directly — all execution goes through `runTest()`
## Phase 4: Edge Cases
- No changed files → inform user, suggest `--all`
- No matching test files → inform user with list of changed files that lack tests
- `--all` flag → skip detection, call `runTest()` with `pkg="./..."` (uses existing infrastructure, not raw `go test`)
- Mixed renames and edits → deduplicate test file list
- Non-Go files changed → skip silently (only `.go` files trigger detection)
## Files to Modify
- `internal/cmd/test/cmd_main.go` (add `--all`, `--filter`, `--base` flags)
- `internal/cmd/test/cmd_runner.go` (add change detection logic before calling existing `runTest()`)
- `internal/cmd/test/cmd_detect.go` (new — git diff parsing and file-to-test mapping)
## Testing
- Add `internal/cmd/test/cmd_detect_test.go` with unit tests for:
- File-to-test mapping (1:1, 1:N, renames, deletes)
- Git diff parsing (`--name-only`, `--name-status`)
- CI vs local context detection
- Manual testing with actual git changes

View file

@ -0,0 +1,36 @@
# Issue 258: Smart Test Detection
## Original Issue
<https://forge.lthn.ai/core/cli/issues/258>
## Summary
Make `core test` smart — detect changed Go files and run only relevant tests.
> **Scope:** Go-only. The existing `core test` command (`internal/cmd/test/`) targets Go projects (requires `go.mod`). Future language support (PHP, etc.) would be added as separate detection strategies, but this issue covers Go only.
## Commands
```bash
core test # Run tests for changed files only
core test --all # Run all tests (skip detection)
core test --filter UserTest # Run specific test pattern
core test --coverage # With coverage report
core test --base origin/dev # Compare against specific base branch (CI)
```
## Acceptance Criteria
- [ ] Detect changed `.go` files via `git diff` (local: `HEAD`, CI: `origin/dev...HEAD`)
- [ ] Handle renames, deletes, and new files via `git diff --name-status`
- [ ] Map source files to test files using N:M discovery (`foo.go``foo_test.go`, `foo_integration_test.go`, etc.)
- [ ] Warn when changed files have no corresponding tests
- [ ] Execute tests through existing `runTest()` infrastructure (not raw `go test`)
- [ ] Support `--all` flag to skip detection and run all tests
- [ ] Support `--filter` flag for test name pattern matching
- [ ] Support `--coverage` flag for coverage reports
- [ ] Support `--base` flag for CI/PR diff context
## Technical Context
- Existing `core test` command: `internal/cmd/test/cmd_main.go`
- Existing test runner: `internal/cmd/test/cmd_runner.go` (`runTest()`)
- Output parsing: `internal/cmd/test/cmd_output.go`
- Command registration: `internal/cmd/test/cmd_commands.go` via `cli.RegisterCommands()`
- Follow existing patterns in `internal/cmd/test/`

View file

@ -1,4 +1,12 @@
# Core CLI Docker image build + push
# Host UK Production Deployment Pipeline
# Runs on Forgejo Actions (gitea.snider.dev)
# Runner: build.de.host.uk.com
#
# Workflow:
# 1. composer install + test
# 2. npm ci + build
# 3. docker build + push
# 4. Coolify deploy webhook (rolling restart)
name: Deploy
@ -7,11 +15,132 @@ on:
branches: [main]
workflow_dispatch:
env:
REGISTRY: dappco.re/osi
IMAGE_APP: host-uk/app
IMAGE_WEB: host-uk/web
IMAGE_CORE: host-uk/core
jobs:
build:
uses: core/go-devops/.forgejo/workflows/docker-publish.yml@main
with:
image: lthn/core
dockerfile: Dockerfile
registry: docker.io
secrets: inherit
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
extensions: bcmath, gd, intl, mbstring, pdo_mysql, redis, zip
coverage: none
- name: Install Composer dependencies
run: composer install --no-interaction --prefer-dist
- name: Run tests
run: composer test
- name: Check code style
run: ./vendor/bin/pint --test
build-app:
name: Build App Image
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Login to registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
- name: Build and push app image
run: |
SHA=$(git rev-parse --short HEAD)
docker build \
-f docker/Dockerfile.app \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_APP }}:${SHA} \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_APP }}:latest \
.
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_APP }}:${SHA}
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_APP }}:latest
build-web:
name: Build Web Image
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login to registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
- name: Build and push web image
run: |
SHA=$(git rev-parse --short HEAD)
docker build \
-f docker/Dockerfile.web \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_WEB }}:${SHA} \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_WEB }}:latest \
.
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_WEB }}:${SHA}
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_WEB }}:latest
build-core:
name: Build Core Image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
- name: Build core binary
run: |
go build -ldflags '-s -w' -o bin/core .
- name: Login to registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
- name: Build and push core image
run: |
SHA=$(git rev-parse --short HEAD)
cat > Dockerfile.core <<'EOF'
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
COPY bin/core /usr/local/bin/core
ENTRYPOINT ["core"]
EOF
docker build \
-f Dockerfile.core \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_CORE }}:${SHA} \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_CORE }}:latest \
.
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_CORE }}:${SHA}
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_CORE }}:latest
deploy:
name: Deploy to Production
needs: [build-app, build-web, build-core]
runs-on: ubuntu-latest
steps:
- name: Trigger Coolify deploy
run: |
curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}" \
"${{ secrets.COOLIFY_URL }}/api/v1/deploy" \
-H "Content-Type: application/json" \
-d '{"uuid": "${{ secrets.COOLIFY_APP_UUID }}", "force": false}'
- name: Wait for deployment
run: |
echo "Deployment triggered. Coolify will perform rolling restart."
echo "Monitor at: ${{ secrets.COOLIFY_URL }}"

View file

@ -1,5 +1,6 @@
# Security scanning via reusable workflow
# Source: core/go-devops/.forgejo/workflows/security-scan.yml
# Sovereign security scanning — no cloud dependencies
# Replaces: GitHub Dependabot, CodeQL, Advanced Security
# PCI DSS: Req 6.3.2 (code review), Req 11.3 (vulnerability scanning)
name: Security Scan
@ -10,6 +11,40 @@ on:
branches: [main]
jobs:
security:
uses: core/go-devops/.forgejo/workflows/security-scan.yml@main
secrets: inherit
govulncheck:
name: Go Vulnerability Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck
run: govulncheck ./...
gitleaks:
name: Secret Detection
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install gitleaks
run: |
GITLEAKS_VERSION=$(curl -s https://api.github.com/repos/gitleaks/gitleaks/releases/latest | jq -r '.tag_name' | tr -d 'v')
curl -sL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" | tar xz -C /usr/local/bin gitleaks
- name: Scan for secrets
run: gitleaks detect --source . --no-banner
trivy:
name: Dependency & Config Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Trivy
run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
- name: Filesystem scan
run: trivy fs --scanners vuln,secret,misconfig --severity HIGH,CRITICAL --exit-code 1 .

11
.gemini/settings.json Normal file
View file

@ -0,0 +1,11 @@
{
"general": {
"sessionRetention": {
"enabled": true
},
"enablePromptCompletion": true
},
"experimental": {
"plan": true
}
}

1
.gitignore vendored
View file

@ -17,7 +17,6 @@ dist/
tasks
/cli
/core
local.test
/i18n-validate
.angular/

52
.woodpecker/bugseti.yml Normal file
View file

@ -0,0 +1,52 @@
when:
- event: tag
ref: "refs/tags/bugseti-v*"
- event: push
branch: main
path: "cmd/bugseti/**"
steps:
- name: frontend
image: node:22-bookworm
commands:
- cd cmd/bugseti/frontend
- npm ci --prefer-offline
- npm run build
- name: build-linux
image: golang:1.25-bookworm
environment:
CGO_ENABLED: "1"
GOOS: linux
GOARCH: amd64
commands:
- apt-get update -qq && apt-get install -y -qq libgtk-3-dev libwebkit2gtk-4.1-dev > /dev/null 2>&1
- cd cmd/bugseti
- go build -tags production -trimpath -buildvcs=false -ldflags="-w -s" -o ../../bin/bugseti
depends_on: [frontend]
- name: package
image: alpine:3.21
commands:
- cd bin
- tar czf bugseti-linux-amd64.tar.gz bugseti
- sha256sum bugseti-linux-amd64.tar.gz > bugseti-linux-amd64.tar.gz.sha256
- echo "=== Package ==="
- ls -lh bugseti-linux-amd64.*
- cat bugseti-linux-amd64.tar.gz.sha256
depends_on: [build-linux]
- name: release
image: plugins/gitea-release
settings:
api_key:
from_secret: forgejo_token
base_url: https://forge.lthn.io
files:
- bin/bugseti-linux-amd64.tar.gz
- bin/bugseti-linux-amd64.tar.gz.sha256
title: ${CI_COMMIT_TAG}
note: "BugSETI ${CI_COMMIT_TAG} — Linux amd64 build"
when:
- event: tag
depends_on: [package]

21
.woodpecker/core.yml Normal file
View file

@ -0,0 +1,21 @@
when:
- event: [push, pull_request, manual]
steps:
- name: build
image: golang:1.25-bookworm
commands:
- go version
- go mod download
- >-
go build
-ldflags "-X forge.lthn.ai/core/cli/pkg/cli.AppVersion=ci
-X forge.lthn.ai/core/cli/pkg/cli.BuildCommit=${CI_COMMIT_SHA:0:7}
-X forge.lthn.ai/core/cli/pkg/cli.BuildDate=$(date -u +%Y%m%d)"
-o ./bin/core .
- ./bin/core --version
- name: test
image: golang:1.25-bookworm
commands:
- go test -short -count=1 -timeout 120s ./...

143
AUDIT-DEPENDENCIES.md Normal file
View file

@ -0,0 +1,143 @@
# Dependency Security Audit
**Date:** 2026-02-02
**Auditor:** Claude Code
**Project:** host-uk/core (Go CLI)
## Executive Summary
**No vulnerabilities found** in current dependencies.
All modules verified successfully with `go mod verify` and `govulncheck`.
---
## Dependency Analysis
### Direct Dependencies (15)
| Package | Version | Purpose | Status |
|---------|---------|---------|--------|
| github.com/Snider/Borg | v0.1.0 | Framework utilities | ✅ Verified |
| github.com/getkin/kin-openapi | v0.133.0 | OpenAPI parsing | ✅ Verified |
| github.com/leaanthony/debme | v1.2.1 | Debounce utilities | ✅ Verified |
| github.com/leaanthony/gosod | v1.0.4 | Go service utilities | ✅ Verified |
| github.com/minio/selfupdate | v0.6.0 | Self-update mechanism | ✅ Verified |
| github.com/modelcontextprotocol/go-sdk | v1.2.0 | MCP SDK | ✅ Verified |
| github.com/oasdiff/oasdiff | v1.11.8 | OpenAPI diff | ✅ Verified |
| github.com/spf13/cobra | v1.10.2 | CLI framework | ✅ Verified |
| github.com/stretchr/testify | v1.11.1 | Testing assertions | ✅ Verified |
| golang.org/x/mod | v0.32.0 | Module utilities | ✅ Verified |
| golang.org/x/net | v0.49.0 | Network utilities | ✅ Verified |
| golang.org/x/oauth2 | v0.34.0 | OAuth2 client | ✅ Verified |
| golang.org/x/term | v0.39.0 | Terminal utilities | ✅ Verified |
| golang.org/x/text | v0.33.0 | Text processing | ✅ Verified |
| gopkg.in/yaml.v3 | v3.0.1 | YAML parser | ✅ Verified |
### Transitive Dependencies
- **Total modules:** 161 indirect dependencies
- **Verification:** All modules verified via `go mod verify`
- **Integrity:** go.sum contains 18,380 bytes of checksums
### Notable Indirect Dependencies
| Package | Purpose | Risk Assessment |
|---------|---------|-----------------|
| github.com/go-git/go-git/v5 | Git operations | Low - well-maintained |
| github.com/ProtonMail/go-crypto | Cryptography | Low - security-focused org |
| github.com/cloudflare/circl | Cryptographic primitives | Low - Cloudflare maintained |
| cloud.google.com/go | Google Cloud SDK | Low - Google maintained |
---
## Vulnerability Scan Results
### govulncheck Output
```
$ govulncheck ./...
No vulnerabilities found.
```
### go mod verify Output
```
$ go mod verify
all modules verified
```
---
## Lock Files
| File | Status | Notes |
|------|--------|-------|
| go.mod | ✅ Committed | 2,995 bytes, properly formatted |
| go.sum | ✅ Committed | 18,380 bytes, integrity hashes present |
| go.work | ✅ Committed | Workspace configuration |
| go.work.sum | ✅ Committed | Workspace checksums |
---
## Supply Chain Assessment
### Package Sources
- ✅ All dependencies from official Go module proxy (proxy.golang.org)
- ✅ No private/unverified package sources
- ✅ Checksum database verification enabled (sum.golang.org)
### Typosquatting Risk
- **Low risk** - all dependencies are from well-known organizations:
- golang.org/x/* (Go team)
- github.com/spf13/* (Steve Francia - Cobra maintainer)
- github.com/stretchr/* (Stretchr - testify maintainers)
- cloud.google.com/go/* (Google)
### Build Process Security
- ✅ Go modules with verified checksums
- ✅ Reproducible builds via go.sum
- ✅ CI runs `go mod verify` before builds
---
## Recommendations
### Immediate Actions
None required - no vulnerabilities detected.
### Ongoing Maintenance
1. **Enable Dependabot** - Automated dependency updates via GitHub
2. **Regular audits** - Run `govulncheck ./...` in CI pipeline
3. **Version pinning** - All dependencies are properly pinned
### CI Integration
Add to CI workflow:
```yaml
- name: Verify dependencies
run: go mod verify
- name: Check vulnerabilities
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
```
---
## Appendix: Full Dependency Tree
Run `go mod graph` to generate the complete dependency tree.
Total dependency relationships: 445
---
*Audit generated by Claude Code on 2026-02-02*

View file

@ -1,21 +0,0 @@
# Host UK — Core CLI Container
# Multi-stage build: Go binary in distroless-style Alpine
#
# Build: docker build -f docker/Dockerfile.core -t lthn/core:latest .
FROM golang:1.26-alpine AS build
RUN apk add --no-cache git ca-certificates
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -trimpath -ldflags '-s -w' -o /core .
FROM alpine:3.21
RUN apk add --no-cache ca-certificates
COPY --from=build /core /usr/local/bin/core
RUN adduser -D -h /home/core core
USER core
ENTRYPOINT ["core"]

55
GEMINI.md Normal file
View file

@ -0,0 +1,55 @@
# GEMINI.md
This file provides guidance for agentic interactions within this repository, specifically for Gemini and other MCP-compliant agents.
## Agentic Context & MCP
This project is built with an **Agentic** design philosophy. It is not exclusive to any single LLM provider (like Claude).
- **MCP Support**: The system is designed to leverage the Model Context Protocol (MCP) to provide rich context and tools to agents.
- **Developer Image**: You are running within a standardized developer image (`host-uk/core` dev environment), ensuring consistent tooling and configuration.
## Core CLI (Agent Interface)
The `core` command is the primary interface for agents to manage the project. Agents should **always** prefer `core` commands over raw shell commands (like `go test`, `php artisan`, etc.).
### Key Commands for Agents
| Task | Command | Notes |
|------|---------|-------|
| **Health Check** | `core doctor` | Verify tools and environment |
| **Repo Status** | `core dev health` | Quick summary of all repos |
| **Work Status** | `core dev work --status` | Detailed dirty/ahead status |
| **Run Tests** | `core go test` | Run Go tests with correct flags |
| **Coverage** | `core go cov` | Generate coverage report |
| **Build** | `core build` | Build the project safely |
| **Search Code** | `core pkg search` | Find packages/repos |
## Project Architecture
Core is a Web3 Framework written in Go using Wails v3.
### Core Framework
- **Services**: Managed via dependency injection (`ServiceFor[T]()`).
- **Lifecycle**: `OnStartup` and `OnShutdown` hooks.
- **IPC**: Message-passing system for service communication.
### Development Workflow
1. **Check State**: `core dev work --status`
2. **Make Changes**: Modify code, add tests.
3. **Verify**: `core go test` (or `core php test` for PHP components).
4. **Commit**: `core dev commit` (or standard git if automated).
5. **Push**: `core dev push` (handles multiple repos).
## Testing Standards
- **Suffix Pattern**:
- `_Good`: Happy path
- `_Bad`: Expected errors
- `_Ugly`: Edge cases/panics
## Go Workspace
The project uses Go workspaces (`go.work`). Always run `core go work sync` after modifying modules.

166
ISSUES_TRIAGE.md Normal file
View file

@ -0,0 +1,166 @@
# Issues Triage
Generated: 2026-02-02
## Summary
- **Total Open Issues**: 46
- **High Priority**: 6
- **Audit Meta-Issues**: 13 (for Jules AI)
- **Audit Derived Issues**: 20 (created from audits)
---
## High Priority Issues
| # | Title | Labels |
|---|-------|--------|
| 183 | audit: OWASP Top 10 security review | priority:high, jules |
| 189 | audit: Test coverage and quality | priority:high, jules |
| 191 | audit: API design and consistency | priority:high, jules |
| 218 | Increase test coverage for low-coverage packages | priority:high, testing |
| 219 | Add tests for edge cases, error paths, integration | priority:high, testing |
| 168 | feat(crypt): Implement standalone pkg/crypt | priority:high, enhancement |
---
## Audit Meta-Issues (For Jules AI)
These are high-level audit tasks that spawn sub-issues:
| # | Title | Complexity |
|---|-------|------------|
| 183 | audit: OWASP Top 10 security review | large |
| 184 | audit: Authentication and authorization flows | medium |
| 186 | audit: Secrets, credentials, and configuration security | medium |
| 187 | audit: Error handling and logging practices | medium |
| 188 | audit: Code complexity and maintainability | large |
| 189 | audit: Test coverage and quality | large |
| 190 | audit: Performance bottlenecks and optimization | large |
| 191 | audit: API design and consistency | large |
| 192 | audit: Documentation completeness and quality | large |
| 193 | audit: Developer experience (DX) review | large |
| 197 | [Audit] Concurrency and Race Condition Analysis | medium |
| 198 | [Audit] CI/CD Pipeline Security | medium |
| 199 | [Audit] Architecture Patterns | large |
| 201 | [Audit] Error Handling and Recovery | medium |
| 202 | [Audit] Configuration Management | medium |
---
## By Category
### Security (4 issues)
| # | Title | Priority |
|---|-------|----------|
| 221 | Remove StrictHostKeyChecking=no from SSH commands | - |
| 222 | Sanitize user input in execInContainer to prevent injection | - |
| 183 | audit: OWASP Top 10 security review | high |
| 213 | Add logging for security events (authentication, access) | - |
### Testing (3 issues)
| # | Title | Priority |
|---|-------|----------|
| 218 | Increase test coverage for low-coverage packages | high |
| 219 | Add tests for edge cases, error paths, integration | high |
| 220 | Configure branch coverage measurement in test tooling | - |
### Error Handling (4 issues)
| # | Title |
|---|-------|
| 227 | Standardize on cli.Error for user-facing errors, deprecate cli.Fatal |
| 228 | Implement panic recovery mechanism with graceful shutdown |
| 229 | Log all errors at handling point with contextual information |
| 230 | Centralize user-facing error strings in i18n translation files |
### Documentation (6 issues)
| # | Title |
|---|-------|
| 231 | Update README.md to reflect actual configuration management |
| 233 | Add CONTRIBUTING.md with contribution guidelines |
| 234 | Add CHANGELOG.md to track version changes |
| 235 | Add user documentation: user guide, FAQ, troubleshooting |
| 236 | Add configuration documentation to README |
| 237 | Add Architecture Decision Records (ADRs) |
### Architecture (3 issues)
| # | Title |
|---|-------|
| 215 | Refactor Core struct to smaller, focused components |
| 216 | Introduce typed messaging system for IPC (replace interface{}) |
| 232 | Create centralized configuration service |
### Performance (2 issues)
| # | Title |
|---|-------|
| 224 | Add streaming API to pkg/io/local for large file handling |
| 225 | Use background goroutines for long-running operations |
### Logging (3 issues)
| # | Title |
|---|-------|
| 212 | Implement structured logging (JSON format) |
| 213 | Add logging for security events |
| 214 | Implement log retention policy |
### New Features (7 issues)
| # | Title | Priority |
|---|-------|----------|
| 168 | feat(crypt): Implement standalone pkg/crypt | high |
| 167 | feat(config): Implement standalone pkg/config | - |
| 170 | feat(plugin): Consolidate pkg/module into pkg/plugin | - |
| 171 | feat(cli): Implement build variants | - |
| 217 | Implement authentication and authorization features | - |
| 211 | feat(setup): add .core/setup.yaml for dev environment | - |
### Help System (5 issues)
| # | Title | Complexity |
|---|-------|------------|
| 133 | feat(help): Implement display-agnostic help system | large |
| 134 | feat(help): Remove Wails dependencies from pkg/help | large |
| 135 | docs(help): Create help content for core CLI | large |
| 136 | feat(help): Add CLI help command | small |
| 138 | feat(help): Implement Catalog and Topic types | large |
| 139 | feat(help): Implement full-text search | small |
---
## Potential Duplicates / Overlaps
1. **Error Handling**: #187, #201, #227-230 all relate to error handling
2. **Documentation**: #192, #231-237 all relate to documentation
3. **Configuration**: #202, #167, #232 all relate to configuration
4. **Security Audits**: #183, #184, #186, #221, #222 all relate to security
---
## Recommendations
1. **Close audit meta-issues as work is done**: Issues #183-202 are meta-audit issues that should be closed once their derived issues are created/completed.
2. **Link related issues**: Create sub-issue relationships:
- #187 (audit: error handling) -> #227, #228, #229, #230
- #192 (audit: docs) -> #231, #233, #234, #235, #236, #237
- #202 (audit: config) -> #167, #232
3. **Good first issues**: #136, #139 are marked as good first issues
4. **Consider closing duplicates**:
- #187 vs #201 (both about error handling)
- #192 vs #231-237 (documentation)
5. **Priority order for development**:
1. Security fixes (#221, #222)
2. Test coverage (#218, #219)
3. Core infrastructure (#168 - crypt, #167 - config)
4. Error handling standardization (#227-230)
5. Documentation (#233-237)

20
Makefile Normal file
View file

@ -0,0 +1,20 @@
.PHONY: all dev prod-docs development-docs
all:
(cd cmd/core-gui && task build)
.ONESHELL:
dev:
(cd cmd/core-gui && task dev)
pre-commit:
coderabbit review --prompt-only
development-docs:
@echo "Running development documentation Website..."
@(cd pkg/core/docs && mkdocs serve -w src)
prod-docs:
@echo "Generating documentation tp Repo Root..."
@(cd pkg/core/docs && mkdocs build -d public && cp -r src public)
@echo "Documentation generated at docs/index.html"

6
Taskfile.yaml Normal file
View file

@ -0,0 +1,6 @@
version: '3'
tasks:
build:
cmds:
- go build -o build/bin/core cmd/app/main.go

View file

@ -148,6 +148,101 @@ tasks:
- task: test
- task: review
# --- i18n ---
i18n:generate:
desc: "Regenerate i18n key constants"
cmds:
- go generate ./pkg/i18n/...
i18n:validate:
desc: "Validate i18n key usage"
cmds:
- go run ./internal/tools/i18n-validate ./...
# --- Core IDE (Wails v3) ---
ide:dev:
desc: "Run Core IDE in Wails dev mode"
dir: cmd/core-ide
cmds:
- cd frontend && npm install && npm run build
- wails3 dev
ide:build:
desc: "Build Core IDE production binary"
dir: cmd/core-ide
cmds:
- cd frontend && npm install && npm run build
- wails3 build
ide:frontend:
desc: "Build Core IDE frontend only"
dir: cmd/core-ide/frontend
cmds:
- npm install
- npm run build
# --- Core App (FrankenPHP + Wails v3) ---
app:setup:
desc: "Install PHP-ZTS build dependency for Core App"
cmds:
- brew tap shivammathur/php 2>/dev/null || true
- brew install shivammathur/php/php@8.4-zts
app:composer:
desc: "Install Laravel dependencies for Core App"
dir: cmd/core-app/laravel
cmds:
- composer install --no-dev --optimize-autoloader --no-interaction
app:build:
desc: "Build Core App (FrankenPHP + Laravel desktop binary)"
dir: cmd/core-app
env:
CGO_ENABLED: "1"
CGO_CFLAGS:
sh: /opt/homebrew/opt/php@8.4-zts/bin/php-config --includes
CGO_LDFLAGS:
sh: "echo -L/opt/homebrew/opt/php@8.4-zts/lib $(/opt/homebrew/opt/php@8.4-zts/bin/php-config --ldflags) $(/opt/homebrew/opt/php@8.4-zts/bin/php-config --libs)"
cmds:
- go build -tags nowatcher -o ../../bin/core-app .
app:dev:
desc: "Build and run Core App"
dir: cmd/core-app
env:
CGO_ENABLED: "1"
CGO_CFLAGS:
sh: /opt/homebrew/opt/php@8.4-zts/bin/php-config --includes
CGO_LDFLAGS:
sh: "echo -L/opt/homebrew/opt/php@8.4-zts/lib $(/opt/homebrew/opt/php@8.4-zts/bin/php-config --ldflags) $(/opt/homebrew/opt/php@8.4-zts/bin/php-config --libs)"
DYLD_LIBRARY_PATH: "/opt/homebrew/opt/php@8.4-zts/lib"
cmds:
- go build -tags nowatcher -o ../../bin/core-app .
- ../../bin/core-app
# --- BugSETI (Wails v3 System Tray) ---
bugseti:dev:
desc: "Build and run BugSETI (production binary with embedded frontend)"
dir: cmd/bugseti
cmds:
- cd frontend && npm install && npm run build
- go build -buildvcs=false -o ../../bin/bugseti .
- ../../bin/bugseti
bugseti:build:
desc: "Build BugSETI production binary"
dir: cmd/bugseti
cmds:
- cd frontend && npm install && npm run build
- go build -trimpath -buildvcs=false -ldflags="-w -s" -o ../../bin/bugseti .
bugseti:frontend:
desc: "Build BugSETI frontend only"
dir: cmd/bugseti/frontend
cmds:
- npm install
- npm run build
# --- Multi-repo (when in workspace) ---
dev:health:
desc: "Check health of all repos"

349
cmd/ai/cmd_agent.go Normal file
View file

@ -0,0 +1,349 @@
package ai
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"forge.lthn.ai/core/go/pkg/agentci"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/config"
)
// AddAgentCommands registers the 'agent' subcommand group under 'ai'.
func AddAgentCommands(parent *cli.Command) {
agentCmd := &cli.Command{
Use: "agent",
Short: "Manage AgentCI dispatch targets",
}
agentCmd.AddCommand(agentAddCmd())
agentCmd.AddCommand(agentListCmd())
agentCmd.AddCommand(agentStatusCmd())
agentCmd.AddCommand(agentLogsCmd())
agentCmd.AddCommand(agentSetupCmd())
agentCmd.AddCommand(agentRemoveCmd())
parent.AddCommand(agentCmd)
}
func loadConfig() (*config.Config, error) {
return config.New()
}
func agentAddCmd() *cli.Command {
cmd := &cli.Command{
Use: "add <name> <user@host>",
Short: "Add an agent to the config and verify SSH",
Args: cli.ExactArgs(2),
RunE: func(cmd *cli.Command, args []string) error {
name := args[0]
host := args[1]
forgejoUser, _ := cmd.Flags().GetString("forgejo-user")
if forgejoUser == "" {
forgejoUser = name
}
queueDir, _ := cmd.Flags().GetString("queue-dir")
if queueDir == "" {
queueDir = "/home/claude/ai-work/queue"
}
model, _ := cmd.Flags().GetString("model")
dualRun, _ := cmd.Flags().GetBool("dual-run")
// Scan and add host key to known_hosts.
parts := strings.Split(host, "@")
hostname := parts[len(parts)-1]
fmt.Printf("Scanning host key for %s... ", hostname)
scanCmd := exec.Command("ssh-keyscan", "-H", hostname)
keys, err := scanCmd.Output()
if err != nil {
fmt.Println(errorStyle.Render("FAILED"))
return fmt.Errorf("failed to scan host keys: %w", err)
}
home, _ := os.UserHomeDir()
knownHostsPath := filepath.Join(home, ".ssh", "known_hosts")
f, err := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("failed to open known_hosts: %w", err)
}
if _, err := f.Write(keys); err != nil {
f.Close()
return fmt.Errorf("failed to write known_hosts: %w", err)
}
f.Close()
fmt.Println(successStyle.Render("OK"))
// Test SSH with strict host key checking.
fmt.Printf("Testing SSH to %s... ", host)
testCmd := agentci.SecureSSHCommand(host, "echo ok")
out, err := testCmd.CombinedOutput()
if err != nil {
fmt.Println(errorStyle.Render("FAILED"))
return fmt.Errorf("SSH failed: %s", strings.TrimSpace(string(out)))
}
fmt.Println(successStyle.Render("OK"))
cfg, err := loadConfig()
if err != nil {
return err
}
ac := agentci.AgentConfig{
Host: host,
QueueDir: queueDir,
ForgejoUser: forgejoUser,
Model: model,
DualRun: dualRun,
Active: true,
}
if err := agentci.SaveAgent(cfg, name, ac); err != nil {
return err
}
fmt.Printf("Agent %s added (%s)\n", successStyle.Render(name), host)
return nil
},
}
cmd.Flags().String("forgejo-user", "", "Forgejo username (defaults to agent name)")
cmd.Flags().String("queue-dir", "", "Remote queue directory (default: /home/claude/ai-work/queue)")
cmd.Flags().String("model", "sonnet", "Primary AI model")
cmd.Flags().Bool("dual-run", false, "Enable Clotho dual-run verification")
return cmd
}
func agentListCmd() *cli.Command {
return &cli.Command{
Use: "list",
Short: "List configured agents",
RunE: func(cmd *cli.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
agents, err := agentci.ListAgents(cfg)
if err != nil {
return err
}
if len(agents) == 0 {
fmt.Println(dimStyle.Render("No agents configured. Use 'core ai agent add' to add one."))
return nil
}
table := cli.NewTable("NAME", "HOST", "MODEL", "DUAL", "ACTIVE", "QUEUE")
for name, ac := range agents {
active := dimStyle.Render("no")
if ac.Active {
active = successStyle.Render("yes")
}
dual := dimStyle.Render("no")
if ac.DualRun {
dual = successStyle.Render("yes")
}
// Quick SSH check for queue depth.
queue := dimStyle.Render("-")
checkCmd := agentci.SecureSSHCommand(ac.Host, fmt.Sprintf("ls %s/ticket-*.json 2>/dev/null | wc -l", ac.QueueDir))
out, err := checkCmd.Output()
if err == nil {
n := strings.TrimSpace(string(out))
if n != "0" {
queue = n
} else {
queue = "0"
}
}
table.AddRow(name, ac.Host, ac.Model, dual, active, queue)
}
table.Render()
return nil
},
}
}
func agentStatusCmd() *cli.Command {
return &cli.Command{
Use: "status <name>",
Short: "Check agent status via SSH",
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
name := args[0]
cfg, err := loadConfig()
if err != nil {
return err
}
agents, err := agentci.ListAgents(cfg)
if err != nil {
return err
}
ac, ok := agents[name]
if !ok {
return fmt.Errorf("agent %q not found", name)
}
script := `
echo "=== Queue ==="
ls ~/ai-work/queue/ticket-*.json 2>/dev/null | wc -l
echo "=== Active ==="
ls ~/ai-work/active/ticket-*.json 2>/dev/null || echo "none"
echo "=== Done ==="
ls ~/ai-work/done/ticket-*.json 2>/dev/null | wc -l
echo "=== Lock ==="
if [ -f ~/ai-work/.runner.lock ]; then
PID=$(cat ~/ai-work/.runner.lock)
if kill -0 "$PID" 2>/dev/null; then
echo "RUNNING (PID $PID)"
else
echo "STALE (PID $PID)"
fi
else
echo "IDLE"
fi
`
sshCmd := agentci.SecureSSHCommand(ac.Host, script)
sshCmd.Stdout = os.Stdout
sshCmd.Stderr = os.Stderr
return sshCmd.Run()
},
}
}
func agentLogsCmd() *cli.Command {
cmd := &cli.Command{
Use: "logs <name>",
Short: "Stream agent runner logs",
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
name := args[0]
follow, _ := cmd.Flags().GetBool("follow")
lines, _ := cmd.Flags().GetInt("lines")
cfg, err := loadConfig()
if err != nil {
return err
}
agents, err := agentci.ListAgents(cfg)
if err != nil {
return err
}
ac, ok := agents[name]
if !ok {
return fmt.Errorf("agent %q not found", name)
}
remoteCmd := fmt.Sprintf("tail -n %d ~/ai-work/logs/runner.log", lines)
if follow {
remoteCmd = fmt.Sprintf("tail -f -n %d ~/ai-work/logs/runner.log", lines)
}
sshCmd := agentci.SecureSSHCommand(ac.Host, remoteCmd)
sshCmd.Stdout = os.Stdout
sshCmd.Stderr = os.Stderr
sshCmd.Stdin = os.Stdin
return sshCmd.Run()
},
}
cmd.Flags().BoolP("follow", "f", false, "Follow log output")
cmd.Flags().IntP("lines", "n", 50, "Number of lines to show")
return cmd
}
func agentSetupCmd() *cli.Command {
return &cli.Command{
Use: "setup <name>",
Short: "Bootstrap agent machine (create dirs, copy runner, install cron)",
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
name := args[0]
cfg, err := loadConfig()
if err != nil {
return err
}
agents, err := agentci.ListAgents(cfg)
if err != nil {
return err
}
ac, ok := agents[name]
if !ok {
return fmt.Errorf("agent %q not found — use 'core ai agent add' first", name)
}
// Find the setup script relative to the binary or in known locations.
scriptPath := findSetupScript()
if scriptPath == "" {
return fmt.Errorf("agent-setup.sh not found — expected in scripts/ directory")
}
fmt.Printf("Setting up %s on %s...\n", name, ac.Host)
setupCmd := exec.Command("bash", scriptPath, ac.Host)
setupCmd.Stdout = os.Stdout
setupCmd.Stderr = os.Stderr
if err := setupCmd.Run(); err != nil {
return fmt.Errorf("setup failed: %w", err)
}
fmt.Println(successStyle.Render("Setup complete!"))
return nil
},
}
}
func agentRemoveCmd() *cli.Command {
return &cli.Command{
Use: "remove <name>",
Short: "Remove an agent from config",
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
name := args[0]
cfg, err := loadConfig()
if err != nil {
return err
}
if err := agentci.RemoveAgent(cfg, name); err != nil {
return err
}
fmt.Printf("Agent %s removed.\n", name)
return nil
},
}
}
// findSetupScript looks for agent-setup.sh in common locations.
func findSetupScript() string {
exe, _ := os.Executable()
if exe != "" {
dir := filepath.Dir(exe)
candidates := []string{
filepath.Join(dir, "scripts", "agent-setup.sh"),
filepath.Join(dir, "..", "scripts", "agent-setup.sh"),
}
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
return c
}
}
}
cwd, _ := os.Getwd()
if cwd != "" {
p := filepath.Join(cwd, "scripts", "agent-setup.sh")
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}

49
cmd/ai/cmd_ai.go Normal file
View file

@ -0,0 +1,49 @@
// cmd_ai.go defines styles and the AddAgenticCommands function for AI task management.
package ai
import (
"forge.lthn.ai/core/go/pkg/cli"
)
// Style aliases from shared package
var (
successStyle = cli.SuccessStyle
errorStyle = cli.ErrorStyle
dimStyle = cli.DimStyle
truncate = cli.Truncate
formatAge = cli.FormatAge
)
// Task priority/status styles from shared
var (
taskPriorityHighStyle = cli.NewStyle().Foreground(cli.ColourRed500)
taskPriorityMediumStyle = cli.NewStyle().Foreground(cli.ColourAmber500)
taskPriorityLowStyle = cli.NewStyle().Foreground(cli.ColourBlue400)
taskStatusPendingStyle = cli.DimStyle
taskStatusInProgressStyle = cli.NewStyle().Foreground(cli.ColourBlue500)
taskStatusCompletedStyle = cli.SuccessStyle
taskStatusBlockedStyle = cli.ErrorStyle
)
// Task-specific styles (aliases to shared where possible)
var (
taskIDStyle = cli.TitleStyle // Bold + blue
taskTitleStyle = cli.ValueStyle // Light gray
taskLabelStyle = cli.NewStyle().Foreground(cli.ColourViolet500) // Violet for labels
)
// AddAgenticCommands adds the agentic task management commands to the ai command.
func AddAgenticCommands(parent *cli.Command) {
// Task listing and viewing
addTasksCommand(parent)
addTaskCommand(parent)
// Task updates
addTaskUpdateCommand(parent)
addTaskCompleteCommand(parent)
// Git integration
addTaskCommitCommand(parent)
addTaskPRCommand(parent)
}

94
cmd/ai/cmd_commands.go Normal file
View file

@ -0,0 +1,94 @@
// Package ai provides AI agent task management and Claude Code integration.
//
// Commands:
// - tasks: List tasks from the agentic service
// - task: View, claim, or auto-select tasks
// - task:update: Update task status and progress
// - task:complete: Mark tasks as completed or failed
// - task:commit: Create commits with task references
// - task:pr: Create pull requests linked to tasks
// - claude: Claude Code CLI integration (planned)
// - rag: RAG tools (ingest, query, collections)
// - metrics: View AI/security event metrics
package ai
import (
ragcmd "forge.lthn.ai/core/cli/cmd/rag"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
func init() {
cli.RegisterCommands(AddAICommands)
}
var aiCmd = &cli.Command{
Use: "ai",
Short: i18n.T("cmd.ai.short"),
Long: i18n.T("cmd.ai.long"),
}
var claudeCmd = &cli.Command{
Use: "claude",
Short: i18n.T("cmd.ai.claude.short"),
Long: i18n.T("cmd.ai.claude.long"),
}
var claudeRunCmd = &cli.Command{
Use: "run",
Short: i18n.T("cmd.ai.claude.run.short"),
RunE: func(cmd *cli.Command, args []string) error {
return runClaudeCode()
},
}
var claudeConfigCmd = &cli.Command{
Use: "config",
Short: i18n.T("cmd.ai.claude.config.short"),
RunE: func(cmd *cli.Command, args []string) error {
return showClaudeConfig()
},
}
func initCommands() {
// Add Claude subcommands
claudeCmd.AddCommand(claudeRunCmd)
claudeCmd.AddCommand(claudeConfigCmd)
// Add Claude command to ai
aiCmd.AddCommand(claudeCmd)
// Add agentic task commands
AddAgenticCommands(aiCmd)
// Add RAG subcommands (core ai rag ...)
ragcmd.AddRAGSubcommands(aiCmd)
// Add metrics subcommand (core ai metrics)
addMetricsCommand(aiCmd)
// Add agent management commands (core ai agent ...)
AddAgentCommands(aiCmd)
// Add rate limit management commands (core ai ratelimits ...)
AddRateLimitCommands(aiCmd)
// Add dispatch commands (core ai dispatch run/watch/status)
AddDispatchCommands(aiCmd)
}
// AddAICommands registers the 'ai' command and all subcommands.
func AddAICommands(root *cli.Command) {
initCommands()
root.AddCommand(aiCmd)
}
func runClaudeCode() error {
// Placeholder - will integrate with claude CLI
return nil
}
func showClaudeConfig() error {
// Placeholder - will show claude configuration
return nil
}

498
cmd/ai/cmd_dispatch.go Normal file
View file

@ -0,0 +1,498 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/log"
)
// AddDispatchCommands registers the 'dispatch' subcommand group under 'ai'.
// These commands run ON the agent machine to process the work queue.
func AddDispatchCommands(parent *cli.Command) {
dispatchCmd := &cli.Command{
Use: "dispatch",
Short: "Agent work queue processor (runs on agent machine)",
}
dispatchCmd.AddCommand(dispatchRunCmd())
dispatchCmd.AddCommand(dispatchWatchCmd())
dispatchCmd.AddCommand(dispatchStatusCmd())
parent.AddCommand(dispatchCmd)
}
// dispatchTicket represents the work item JSON structure.
type dispatchTicket struct {
ID string `json:"id"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
IssueNumber int `json:"issue_number"`
IssueTitle string `json:"issue_title"`
IssueBody string `json:"issue_body"`
TargetBranch string `json:"target_branch"`
EpicNumber int `json:"epic_number"`
ForgeURL string `json:"forge_url"`
ForgeToken string `json:"forge_token"`
ForgeUser string `json:"forgejo_user"`
Model string `json:"model"`
Runner string `json:"runner"`
Timeout string `json:"timeout"`
CreatedAt string `json:"created_at"`
}
const (
defaultWorkDir = "ai-work"
lockFileName = ".runner.lock"
)
type runnerPaths struct {
root string
queue string
active string
done string
logs string
jobs string
lock string
}
func getPaths(baseDir string) runnerPaths {
if baseDir == "" {
home, _ := os.UserHomeDir()
baseDir = filepath.Join(home, defaultWorkDir)
}
return runnerPaths{
root: baseDir,
queue: filepath.Join(baseDir, "queue"),
active: filepath.Join(baseDir, "active"),
done: filepath.Join(baseDir, "done"),
logs: filepath.Join(baseDir, "logs"),
jobs: filepath.Join(baseDir, "jobs"),
lock: filepath.Join(baseDir, lockFileName),
}
}
func dispatchRunCmd() *cli.Command {
cmd := &cli.Command{
Use: "run",
Short: "Process a single ticket from the queue",
RunE: func(cmd *cli.Command, args []string) error {
workDir, _ := cmd.Flags().GetString("work-dir")
paths := getPaths(workDir)
if err := ensureDispatchDirs(paths); err != nil {
return err
}
if err := acquireLock(paths.lock); err != nil {
log.Info("Runner locked, skipping run", "lock", paths.lock)
return nil
}
defer releaseLock(paths.lock)
ticketFile, err := pickOldestTicket(paths.queue)
if err != nil {
return err
}
if ticketFile == "" {
return nil
}
return processTicket(paths, ticketFile)
},
}
cmd.Flags().String("work-dir", "", "Working directory (default: ~/ai-work)")
return cmd
}
func dispatchWatchCmd() *cli.Command {
cmd := &cli.Command{
Use: "watch",
Short: "Run as a daemon, polling the queue",
RunE: func(cmd *cli.Command, args []string) error {
workDir, _ := cmd.Flags().GetString("work-dir")
interval, _ := cmd.Flags().GetDuration("interval")
paths := getPaths(workDir)
if err := ensureDispatchDirs(paths); err != nil {
return err
}
log.Info("Starting dispatch watcher", "dir", paths.root, "interval", interval)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
ticker := time.NewTicker(interval)
defer ticker.Stop()
runCycle(paths)
for {
select {
case <-ticker.C:
runCycle(paths)
case <-sigChan:
log.Info("Shutting down watcher...")
return nil
case <-ctx.Done():
return nil
}
}
},
}
cmd.Flags().String("work-dir", "", "Working directory (default: ~/ai-work)")
cmd.Flags().Duration("interval", 5*time.Minute, "Polling interval")
return cmd
}
func dispatchStatusCmd() *cli.Command {
cmd := &cli.Command{
Use: "status",
Short: "Show runner status",
RunE: func(cmd *cli.Command, args []string) error {
workDir, _ := cmd.Flags().GetString("work-dir")
paths := getPaths(workDir)
lockStatus := "IDLE"
if data, err := os.ReadFile(paths.lock); err == nil {
pidStr := strings.TrimSpace(string(data))
pid, _ := strconv.Atoi(pidStr)
if isProcessAlive(pid) {
lockStatus = fmt.Sprintf("RUNNING (PID %d)", pid)
} else {
lockStatus = fmt.Sprintf("STALE (PID %d)", pid)
}
}
countFiles := func(dir string) int {
entries, _ := os.ReadDir(dir)
count := 0
for _, e := range entries {
if !e.IsDir() && strings.HasPrefix(e.Name(), "ticket-") {
count++
}
}
return count
}
fmt.Println("=== Agent Dispatch Status ===")
fmt.Printf("Work Dir: %s\n", paths.root)
fmt.Printf("Status: %s\n", lockStatus)
fmt.Printf("Queue: %d\n", countFiles(paths.queue))
fmt.Printf("Active: %d\n", countFiles(paths.active))
fmt.Printf("Done: %d\n", countFiles(paths.done))
return nil
},
}
cmd.Flags().String("work-dir", "", "Working directory (default: ~/ai-work)")
return cmd
}
func runCycle(paths runnerPaths) {
if err := acquireLock(paths.lock); err != nil {
log.Debug("Runner locked, skipping cycle")
return
}
defer releaseLock(paths.lock)
ticketFile, err := pickOldestTicket(paths.queue)
if err != nil {
log.Error("Failed to pick ticket", "error", err)
return
}
if ticketFile == "" {
return
}
if err := processTicket(paths, ticketFile); err != nil {
log.Error("Failed to process ticket", "file", ticketFile, "error", err)
}
}
func processTicket(paths runnerPaths, ticketPath string) error {
fileName := filepath.Base(ticketPath)
log.Info("Processing ticket", "file", fileName)
activePath := filepath.Join(paths.active, fileName)
if err := os.Rename(ticketPath, activePath); err != nil {
return fmt.Errorf("failed to move ticket to active: %w", err)
}
data, err := os.ReadFile(activePath)
if err != nil {
return fmt.Errorf("failed to read ticket: %w", err)
}
var t dispatchTicket
if err := json.Unmarshal(data, &t); err != nil {
return fmt.Errorf("failed to unmarshal ticket: %w", err)
}
jobDir := filepath.Join(paths.jobs, fmt.Sprintf("%s-%s-%d", t.RepoOwner, t.RepoName, t.IssueNumber))
repoDir := filepath.Join(jobDir, t.RepoName)
if err := os.MkdirAll(jobDir, 0755); err != nil {
return err
}
if err := prepareRepo(t, repoDir); err != nil {
reportToForge(t, false, fmt.Sprintf("Git setup failed: %v", err))
moveToDone(paths, activePath, fileName)
return err
}
prompt := buildPrompt(t)
logFile := filepath.Join(paths.logs, fmt.Sprintf("%s-%s-%d.log", t.RepoOwner, t.RepoName, t.IssueNumber))
success, exitCode, runErr := runAgent(t, prompt, repoDir, logFile)
msg := fmt.Sprintf("Agent completed work on #%d. Exit code: %d.", t.IssueNumber, exitCode)
if !success {
msg = fmt.Sprintf("Agent failed on #%d (exit code: %d). Check logs on agent machine.", t.IssueNumber, exitCode)
if runErr != nil {
msg += fmt.Sprintf(" Error: %v", runErr)
}
}
reportToForge(t, success, msg)
moveToDone(paths, activePath, fileName)
log.Info("Ticket complete", "id", t.ID, "success", success)
return nil
}
func prepareRepo(t dispatchTicket, repoDir string) error {
user := t.ForgeUser
if user == "" {
host, _ := os.Hostname()
user = fmt.Sprintf("%s-%s", host, os.Getenv("USER"))
}
cleanURL := strings.TrimPrefix(t.ForgeURL, "https://")
cleanURL = strings.TrimPrefix(cleanURL, "http://")
cloneURL := fmt.Sprintf("https://%s:%s@%s/%s/%s.git", user, t.ForgeToken, cleanURL, t.RepoOwner, t.RepoName)
if _, err := os.Stat(filepath.Join(repoDir, ".git")); err == nil {
log.Info("Updating existing repo", "dir", repoDir)
cmds := [][]string{
{"git", "fetch", "origin"},
{"git", "checkout", t.TargetBranch},
{"git", "pull", "origin", t.TargetBranch},
}
for _, args := range cmds {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = repoDir
if out, err := cmd.CombinedOutput(); err != nil {
if args[1] == "checkout" {
createCmd := exec.Command("git", "checkout", "-b", t.TargetBranch, "origin/"+t.TargetBranch)
createCmd.Dir = repoDir
if _, err2 := createCmd.CombinedOutput(); err2 == nil {
continue
}
}
return fmt.Errorf("git command %v failed: %s", args, string(out))
}
}
} else {
log.Info("Cloning repo", "url", t.RepoOwner+"/"+t.RepoName)
cmd := exec.Command("git", "clone", "-b", t.TargetBranch, cloneURL, repoDir)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git clone failed: %s", string(out))
}
}
return nil
}
func buildPrompt(t dispatchTicket) string {
return fmt.Sprintf(`You are working on issue #%d in %s/%s.
Title: %s
Description:
%s
The repo is cloned at the current directory on branch '%s'.
Create a feature branch from '%s', make minimal targeted changes, commit referencing #%d, and push.
Then create a PR targeting '%s' using the forgejo MCP tools or git push.`,
t.IssueNumber, t.RepoOwner, t.RepoName,
t.IssueTitle,
t.IssueBody,
t.TargetBranch,
t.TargetBranch, t.IssueNumber,
t.TargetBranch,
)
}
func runAgent(t dispatchTicket, prompt, dir, logPath string) (bool, int, error) {
timeout := 30 * time.Minute
if t.Timeout != "" {
if d, err := time.ParseDuration(t.Timeout); err == nil {
timeout = d
}
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
model := t.Model
if model == "" {
model = "sonnet"
}
log.Info("Running agent", "runner", t.Runner, "model", model)
// For Gemini runner, wrap with rate limiting.
if t.Runner == "gemini" {
return executeWithRateLimit(ctx, model, prompt, func() (bool, int, error) {
return execAgent(ctx, t.Runner, model, prompt, dir, logPath)
})
}
return execAgent(ctx, t.Runner, model, prompt, dir, logPath)
}
func execAgent(ctx context.Context, runner, model, prompt, dir, logPath string) (bool, int, error) {
var cmd *exec.Cmd
switch runner {
case "codex":
cmd = exec.CommandContext(ctx, "codex", "exec", "--full-auto", prompt)
case "gemini":
args := []string{"-p", "-", "-y", "-m", model}
cmd = exec.CommandContext(ctx, "gemini", args...)
cmd.Stdin = strings.NewReader(prompt)
default: // claude
cmd = exec.CommandContext(ctx, "claude", "-p", "--model", model, "--dangerously-skip-permissions", "--output-format", "text")
cmd.Stdin = strings.NewReader(prompt)
}
cmd.Dir = dir
f, err := os.Create(logPath)
if err != nil {
return false, -1, err
}
defer f.Close()
cmd.Stdout = f
cmd.Stderr = f
if err := cmd.Run(); err != nil {
exitCode := -1
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
}
return false, exitCode, err
}
return true, 0, nil
}
func reportToForge(t dispatchTicket, success bool, body string) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments",
strings.TrimSuffix(t.ForgeURL, "/"), t.RepoOwner, t.RepoName, t.IssueNumber)
payload := map[string]string{"body": body}
jsonBody, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody))
if err != nil {
log.Error("Failed to create request", "err", err)
return
}
req.Header.Set("Authorization", "token "+t.ForgeToken)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Error("Failed to report to Forge", "err", err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
log.Warn("Forge reported error", "status", resp.Status)
}
}
func moveToDone(paths runnerPaths, activePath, fileName string) {
donePath := filepath.Join(paths.done, fileName)
if err := os.Rename(activePath, donePath); err != nil {
log.Error("Failed to move ticket to done", "err", err)
}
}
func ensureDispatchDirs(p runnerPaths) error {
dirs := []string{p.queue, p.active, p.done, p.logs, p.jobs}
for _, d := range dirs {
if err := os.MkdirAll(d, 0755); err != nil {
return fmt.Errorf("mkdir %s failed: %w", d, err)
}
}
return nil
}
func acquireLock(lockPath string) error {
if data, err := os.ReadFile(lockPath); err == nil {
pidStr := strings.TrimSpace(string(data))
pid, _ := strconv.Atoi(pidStr)
if isProcessAlive(pid) {
return fmt.Errorf("locked by PID %d", pid)
}
log.Info("Removing stale lock", "pid", pid)
_ = os.Remove(lockPath)
}
return os.WriteFile(lockPath, []byte(fmt.Sprintf("%d", os.Getpid())), 0644)
}
func releaseLock(lockPath string) {
_ = os.Remove(lockPath)
}
func isProcessAlive(pid int) bool {
if pid <= 0 {
return false
}
process, err := os.FindProcess(pid)
if err != nil {
return false
}
return process.Signal(syscall.Signal(0)) == nil
}
func pickOldestTicket(queueDir string) (string, error) {
entries, err := os.ReadDir(queueDir)
if err != nil {
return "", err
}
var tickets []string
for _, e := range entries {
if !e.IsDir() && strings.HasPrefix(e.Name(), "ticket-") && strings.HasSuffix(e.Name(), ".json") {
tickets = append(tickets, filepath.Join(queueDir, e.Name()))
}
}
if len(tickets) == 0 {
return "", nil
}
sort.Strings(tickets)
return tickets[0], nil
}

248
cmd/ai/cmd_git.go Normal file
View file

@ -0,0 +1,248 @@
// cmd_git.go implements git integration commands for task commits and PRs.
package ai
import (
"bytes"
"context"
"os"
"os/exec"
"strings"
"time"
"forge.lthn.ai/core/go/pkg/agentic"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
// task:commit command flags
var (
taskCommitMessage string
taskCommitScope string
taskCommitPush bool
)
// task:pr command flags
var (
taskPRTitle string
taskPRDraft bool
taskPRLabels string
taskPRBase string
)
var taskCommitCmd = &cli.Command{
Use: "task:commit [task-id]",
Short: i18n.T("cmd.ai.task_commit.short"),
Long: i18n.T("cmd.ai.task_commit.long"),
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
taskID := args[0]
if taskCommitMessage == "" {
return cli.Err("commit message required")
}
cfg, err := agentic.LoadConfig("")
if err != nil {
return cli.WrapVerb(err, "load", "config")
}
client := agentic.NewClientFromConfig(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Get task details
task, err := client.GetTask(ctx, taskID)
if err != nil {
return cli.WrapVerb(err, "get", "task")
}
// Build commit message with optional scope
commitType := inferCommitType(task.Labels)
var fullMessage string
if taskCommitScope != "" {
fullMessage = cli.Sprintf("%s(%s): %s", commitType, taskCommitScope, taskCommitMessage)
} else {
fullMessage = cli.Sprintf("%s: %s", commitType, taskCommitMessage)
}
// Get current directory
cwd, err := os.Getwd()
if err != nil {
return cli.WrapVerb(err, "get", "working directory")
}
// Check for uncommitted changes
hasChanges, err := agentic.HasUncommittedChanges(ctx, cwd)
if err != nil {
return cli.WrapVerb(err, "check", "git status")
}
if !hasChanges {
cli.Println("No changes to commit")
return nil
}
// Create commit
cli.Print("%s %s\n", dimStyle.Render(">>"), i18n.ProgressSubject("create", "commit for "+taskID))
if err := agentic.AutoCommit(ctx, task, cwd, fullMessage); err != nil {
return cli.WrapAction(err, "commit")
}
cli.Print("%s %s %s\n", successStyle.Render(">>"), i18n.T("i18n.done.commit")+":", fullMessage)
// Push if requested
if taskCommitPush {
cli.Print("%s %s\n", dimStyle.Render(">>"), i18n.Progress("push"))
if err := agentic.PushChanges(ctx, cwd); err != nil {
return cli.WrapAction(err, "push")
}
cli.Print("%s %s\n", successStyle.Render(">>"), i18n.T("i18n.done.push", "changes"))
}
return nil
},
}
var taskPRCmd = &cli.Command{
Use: "task:pr [task-id]",
Short: i18n.T("cmd.ai.task_pr.short"),
Long: i18n.T("cmd.ai.task_pr.long"),
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
taskID := args[0]
cfg, err := agentic.LoadConfig("")
if err != nil {
return cli.WrapVerb(err, "load", "config")
}
client := agentic.NewClientFromConfig(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// Get task details
task, err := client.GetTask(ctx, taskID)
if err != nil {
return cli.WrapVerb(err, "get", "task")
}
// Get current directory
cwd, err := os.Getwd()
if err != nil {
return cli.WrapVerb(err, "get", "working directory")
}
// Check current branch
branch, err := agentic.GetCurrentBranch(ctx, cwd)
if err != nil {
return cli.WrapVerb(err, "get", "branch")
}
if branch == "main" || branch == "master" {
return cli.Err("cannot create PR from %s branch", branch)
}
// Push current branch
cli.Print("%s %s\n", dimStyle.Render(">>"), i18n.ProgressSubject("push", branch))
if err := agentic.PushChanges(ctx, cwd); err != nil {
// Try setting upstream
if _, err := runGitCommand(cwd, "push", "-u", "origin", branch); err != nil {
return cli.WrapVerb(err, "push", "branch")
}
}
// Build PR options
opts := agentic.PROptions{
Title: taskPRTitle,
Draft: taskPRDraft,
Base: taskPRBase,
}
if taskPRLabels != "" {
opts.Labels = strings.Split(taskPRLabels, ",")
}
// Create PR
cli.Print("%s %s\n", dimStyle.Render(">>"), i18n.ProgressSubject("create", "PR"))
prURL, err := agentic.CreatePR(ctx, task, cwd, opts)
if err != nil {
return cli.WrapVerb(err, "create", "PR")
}
cli.Print("%s %s\n", successStyle.Render(">>"), i18n.T("i18n.done.create", "PR"))
cli.Print(" %s %s\n", i18n.Label("url"), prURL)
return nil
},
}
func initGitFlags() {
// task:commit command flags
taskCommitCmd.Flags().StringVarP(&taskCommitMessage, "message", "m", "", i18n.T("cmd.ai.task_commit.flag.message"))
taskCommitCmd.Flags().StringVar(&taskCommitScope, "scope", "", i18n.T("cmd.ai.task_commit.flag.scope"))
taskCommitCmd.Flags().BoolVar(&taskCommitPush, "push", false, i18n.T("cmd.ai.task_commit.flag.push"))
// task:pr command flags
taskPRCmd.Flags().StringVar(&taskPRTitle, "title", "", i18n.T("cmd.ai.task_pr.flag.title"))
taskPRCmd.Flags().BoolVar(&taskPRDraft, "draft", false, i18n.T("cmd.ai.task_pr.flag.draft"))
taskPRCmd.Flags().StringVar(&taskPRLabels, "labels", "", i18n.T("cmd.ai.task_pr.flag.labels"))
taskPRCmd.Flags().StringVar(&taskPRBase, "base", "", i18n.T("cmd.ai.task_pr.flag.base"))
}
func addTaskCommitCommand(parent *cli.Command) {
initGitFlags()
parent.AddCommand(taskCommitCmd)
}
func addTaskPRCommand(parent *cli.Command) {
parent.AddCommand(taskPRCmd)
}
// inferCommitType infers the commit type from task labels.
func inferCommitType(labels []string) string {
for _, label := range labels {
switch strings.ToLower(label) {
case "bug", "bugfix", "fix":
return "fix"
case "docs", "documentation":
return "docs"
case "refactor", "refactoring":
return "refactor"
case "test", "tests", "testing":
return "test"
case "chore":
return "chore"
case "style":
return "style"
case "perf", "performance":
return "perf"
case "ci":
return "ci"
case "build":
return "build"
}
}
return "feat"
}
// runGitCommand runs a git command in the specified directory.
func runGitCommand(dir string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if stderr.Len() > 0 {
return "", cli.Wrap(err, stderr.String())
}
return "", err
}
return stdout.String(), nil
}

131
cmd/ai/cmd_metrics.go Normal file
View file

@ -0,0 +1,131 @@
// cmd_metrics.go implements the metrics viewing command.
package ai
import (
"encoding/json"
"fmt"
"time"
"forge.lthn.ai/core/go/pkg/ai"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
var (
metricsSince string
metricsJSON bool
)
var metricsCmd = &cli.Command{
Use: "metrics",
Short: i18n.T("cmd.ai.metrics.short"),
Long: i18n.T("cmd.ai.metrics.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runMetrics()
},
}
func initMetricsFlags() {
metricsCmd.Flags().StringVar(&metricsSince, "since", "7d", i18n.T("cmd.ai.metrics.flag.since"))
metricsCmd.Flags().BoolVar(&metricsJSON, "json", false, i18n.T("common.flag.json"))
}
func addMetricsCommand(parent *cli.Command) {
initMetricsFlags()
parent.AddCommand(metricsCmd)
}
func runMetrics() error {
since, err := parseDuration(metricsSince)
if err != nil {
return cli.Err("invalid --since value %q: %v", metricsSince, err)
}
sinceTime := time.Now().Add(-since)
events, err := ai.ReadEvents(sinceTime)
if err != nil {
return cli.WrapVerb(err, "read", "metrics")
}
if metricsJSON {
summary := ai.Summary(events)
output, err := json.MarshalIndent(summary, "", " ")
if err != nil {
return cli.Wrap(err, "marshal JSON output")
}
cli.Text(string(output))
return nil
}
summary := ai.Summary(events)
cli.Blank()
cli.Print("%s %s\n", dimStyle.Render("Period:"), metricsSince)
total, _ := summary["total"].(int)
cli.Print("%s %d\n", dimStyle.Render("Total events:"), total)
cli.Blank()
// By type
if byType, ok := summary["by_type"].([]map[string]any); ok && len(byType) > 0 {
cli.Print("%s\n", dimStyle.Render("By type:"))
for _, entry := range byType {
cli.Print(" %-30s %v\n", entry["key"], entry["count"])
}
cli.Blank()
}
// By repo
if byRepo, ok := summary["by_repo"].([]map[string]any); ok && len(byRepo) > 0 {
cli.Print("%s\n", dimStyle.Render("By repo:"))
for _, entry := range byRepo {
cli.Print(" %-30s %v\n", entry["key"], entry["count"])
}
cli.Blank()
}
// By agent
if byAgent, ok := summary["by_agent"].([]map[string]any); ok && len(byAgent) > 0 {
cli.Print("%s\n", dimStyle.Render("By contributor:"))
for _, entry := range byAgent {
cli.Print(" %-30s %v\n", entry["key"], entry["count"])
}
cli.Blank()
}
if len(events) == 0 {
cli.Text(i18n.T("cmd.ai.metrics.none_found"))
}
return nil
}
// parseDuration parses a human-friendly duration like "7d", "24h", "30d".
func parseDuration(s string) (time.Duration, error) {
if len(s) < 2 {
return 0, fmt.Errorf("invalid duration: %s", s)
}
unit := s[len(s)-1]
value := s[:len(s)-1]
var n int
if _, err := fmt.Sscanf(value, "%d", &n); err != nil {
return 0, fmt.Errorf("invalid duration: %s", s)
}
if n <= 0 {
return 0, fmt.Errorf("duration must be positive: %s", s)
}
switch unit {
case 'd':
return time.Duration(n) * 24 * time.Hour, nil
case 'h':
return time.Duration(n) * time.Hour, nil
case 'm':
return time.Duration(n) * time.Minute, nil
default:
return 0, fmt.Errorf("unknown unit %c in duration: %s", unit, s)
}
}

213
cmd/ai/cmd_ratelimits.go Normal file
View file

@ -0,0 +1,213 @@
package ai
import (
"fmt"
"os"
"strconv"
"text/tabwriter"
"time"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/config"
"forge.lthn.ai/core/go/pkg/ratelimit"
)
// AddRateLimitCommands registers the 'ratelimits' subcommand group under 'ai'.
func AddRateLimitCommands(parent *cli.Command) {
rlCmd := &cli.Command{
Use: "ratelimits",
Short: "Manage Gemini API rate limits",
}
rlCmd.AddCommand(rlShowCmd())
rlCmd.AddCommand(rlResetCmd())
rlCmd.AddCommand(rlCountCmd())
rlCmd.AddCommand(rlConfigCmd())
rlCmd.AddCommand(rlCheckCmd())
parent.AddCommand(rlCmd)
}
func rlShowCmd() *cli.Command {
return &cli.Command{
Use: "show",
Short: "Show current rate limit usage",
RunE: func(cmd *cli.Command, args []string) error {
rl, err := ratelimit.New()
if err != nil {
return err
}
if err := rl.Load(); err != nil {
return err
}
stats := rl.AllStats()
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
fmt.Fprintln(w, "MODEL\tRPM\tTPM\tRPD\tSTATUS")
for model, s := range stats {
rpmStr := fmt.Sprintf("%d/%s", s.RPM, formatLimit(s.MaxRPM))
tpmStr := fmt.Sprintf("%d/%s", s.TPM, formatLimit(s.MaxTPM))
rpdStr := fmt.Sprintf("%d/%s", s.RPD, formatLimit(s.MaxRPD))
status := "OK"
if (s.MaxRPM > 0 && s.RPM >= s.MaxRPM) ||
(s.MaxTPM > 0 && s.TPM >= s.MaxTPM) ||
(s.MaxRPD > 0 && s.RPD >= s.MaxRPD) {
status = "LIMITED"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", model, rpmStr, tpmStr, rpdStr, status)
}
w.Flush()
return nil
},
}
}
func rlResetCmd() *cli.Command {
return &cli.Command{
Use: "reset [model]",
Short: "Reset usage counters for a model (or all)",
RunE: func(cmd *cli.Command, args []string) error {
rl, err := ratelimit.New()
if err != nil {
return err
}
if err := rl.Load(); err != nil {
return err
}
model := ""
if len(args) > 0 {
model = args[0]
}
rl.Reset(model)
if err := rl.Persist(); err != nil {
return err
}
if model == "" {
fmt.Println("Reset stats for all models.")
} else {
fmt.Printf("Reset stats for model %q.\n", model)
}
return nil
},
}
}
func rlCountCmd() *cli.Command {
return &cli.Command{
Use: "count <model> <text>",
Short: "Count tokens for text using Gemini API",
Args: cli.ExactArgs(2),
RunE: func(cmd *cli.Command, args []string) error {
model := args[0]
text := args[1]
cfg, err := config.New()
if err != nil {
return err
}
var apiKey string
if err := cfg.Get("agentci.gemini_api_key", &apiKey); err != nil || apiKey == "" {
apiKey = os.Getenv("GEMINI_API_KEY")
}
if apiKey == "" {
return fmt.Errorf("GEMINI_API_KEY not found in config or env")
}
count, err := ratelimit.CountTokens(apiKey, model, text)
if err != nil {
return err
}
fmt.Printf("Model: %s\nTokens: %d\n", model, count)
return nil
},
}
}
func rlConfigCmd() *cli.Command {
return &cli.Command{
Use: "config",
Short: "Show configured quotas",
RunE: func(cmd *cli.Command, args []string) error {
rl, err := ratelimit.New()
if err != nil {
return err
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
fmt.Fprintln(w, "MODEL\tMAX RPM\tMAX TPM\tMAX RPD")
for model, q := range rl.Quotas {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
model,
formatLimit(q.MaxRPM),
formatLimit(q.MaxTPM),
formatLimit(q.MaxRPD))
}
w.Flush()
return nil
},
}
}
func rlCheckCmd() *cli.Command {
return &cli.Command{
Use: "check <model> <estimated-tokens>",
Short: "Check rate limit capacity for a model",
Args: cli.ExactArgs(2),
RunE: func(cmd *cli.Command, args []string) error {
model := args[0]
tokens, err := strconv.Atoi(args[1])
if err != nil {
return fmt.Errorf("invalid token count: %w", err)
}
rl, err := ratelimit.New()
if err != nil {
return err
}
if err := rl.Load(); err != nil {
fmt.Printf("Warning: could not load existing state: %v\n", err)
}
stats := rl.Stats(model)
canSend := rl.CanSend(model, tokens)
status := "RATE LIMITED"
if canSend {
status = "OK"
}
fmt.Printf("Model: %s\n", model)
fmt.Printf("Request Cost: %d tokens\n", tokens)
fmt.Printf("Status: %s\n", status)
fmt.Printf("\nCurrent Usage (1m window):\n")
fmt.Printf(" RPM: %d / %s\n", stats.RPM, formatLimit(stats.MaxRPM))
fmt.Printf(" TPM: %d / %s\n", stats.TPM, formatLimit(stats.MaxTPM))
fmt.Printf(" RPD: %d / %s (reset: %s)\n", stats.RPD, formatLimit(stats.MaxRPD), stats.DayStart.Format(time.RFC3339))
return nil
},
}
}
func formatLimit(limit int) string {
if limit == 0 {
return "∞"
}
if limit >= 1000000 {
return fmt.Sprintf("%dM", limit/1000000)
}
if limit >= 1000 {
return fmt.Sprintf("%dK", limit/1000)
}
return fmt.Sprintf("%d", limit)
}

297
cmd/ai/cmd_tasks.go Normal file
View file

@ -0,0 +1,297 @@
// cmd_tasks.go implements task listing and viewing commands.
package ai
import (
"context"
"os"
"sort"
"strings"
"time"
"forge.lthn.ai/core/go/pkg/agentic"
"forge.lthn.ai/core/go/pkg/ai"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
// tasks command flags
var (
tasksStatus string
tasksPriority string
tasksLabels string
tasksLimit int
tasksProject string
)
// task command flags
var (
taskAutoSelect bool
taskClaim bool
taskShowContext bool
)
var tasksCmd = &cli.Command{
Use: "tasks",
Short: i18n.T("cmd.ai.tasks.short"),
Long: i18n.T("cmd.ai.tasks.long"),
RunE: func(cmd *cli.Command, args []string) error {
limit := tasksLimit
if limit == 0 {
limit = 20
}
cfg, err := agentic.LoadConfig("")
if err != nil {
return cli.WrapVerb(err, "load", "config")
}
client := agentic.NewClientFromConfig(cfg)
opts := agentic.ListOptions{
Limit: limit,
Project: tasksProject,
}
if tasksStatus != "" {
opts.Status = agentic.TaskStatus(tasksStatus)
}
if tasksPriority != "" {
opts.Priority = agentic.TaskPriority(tasksPriority)
}
if tasksLabels != "" {
opts.Labels = strings.Split(tasksLabels, ",")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
tasks, err := client.ListTasks(ctx, opts)
if err != nil {
return cli.WrapVerb(err, "list", "tasks")
}
if len(tasks) == 0 {
cli.Text(i18n.T("cmd.ai.tasks.none_found"))
return nil
}
printTaskList(tasks)
return nil
},
}
var taskCmd = &cli.Command{
Use: "task [task-id]",
Short: i18n.T("cmd.ai.task.short"),
Long: i18n.T("cmd.ai.task.long"),
RunE: func(cmd *cli.Command, args []string) error {
cfg, err := agentic.LoadConfig("")
if err != nil {
return cli.WrapVerb(err, "load", "config")
}
client := agentic.NewClientFromConfig(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var task *agentic.Task
// Get the task ID from args
var taskID string
if len(args) > 0 {
taskID = args[0]
}
if taskAutoSelect {
// Auto-select: find highest priority pending task
tasks, err := client.ListTasks(ctx, agentic.ListOptions{
Status: agentic.StatusPending,
Limit: 50,
})
if err != nil {
return cli.WrapVerb(err, "list", "tasks")
}
if len(tasks) == 0 {
cli.Text(i18n.T("cmd.ai.task.no_pending"))
return nil
}
// Sort by priority (critical > high > medium > low)
priorityOrder := map[agentic.TaskPriority]int{
agentic.PriorityCritical: 0,
agentic.PriorityHigh: 1,
agentic.PriorityMedium: 2,
agentic.PriorityLow: 3,
}
sort.Slice(tasks, func(i, j int) bool {
return priorityOrder[tasks[i].Priority] < priorityOrder[tasks[j].Priority]
})
task = &tasks[0]
taskClaim = true // Auto-select implies claiming
} else {
if taskID == "" {
return cli.Err("%s", i18n.T("cmd.ai.task.id_required"))
}
task, err = client.GetTask(ctx, taskID)
if err != nil {
return cli.WrapVerb(err, "get", "task")
}
}
// Show context if requested
if taskShowContext {
cwd, _ := os.Getwd()
taskCtx, err := agentic.BuildTaskContext(task, cwd)
if err != nil {
cli.Print("%s %s: %s\n", errorStyle.Render(">>"), i18n.T("i18n.fail.build", "context"), err)
} else {
cli.Text(taskCtx.FormatContext())
}
} else {
printTaskDetails(task)
}
if taskClaim && task.Status == agentic.StatusPending {
cli.Blank()
cli.Print("%s %s\n", dimStyle.Render(">>"), i18n.T("cmd.ai.task.claiming"))
claimedTask, err := client.ClaimTask(ctx, task.ID)
if err != nil {
return cli.WrapVerb(err, "claim", "task")
}
// Record task claim event
_ = ai.Record(ai.Event{
Type: "task.claimed",
AgentID: cfg.AgentID,
Data: map[string]any{"task_id": task.ID, "title": task.Title},
})
cli.Print("%s %s\n", successStyle.Render(">>"), i18n.T("i18n.done.claim", "task"))
cli.Print(" %s %s\n", i18n.Label("status"), formatTaskStatus(claimedTask.Status))
}
return nil
},
}
func initTasksFlags() {
// tasks command flags
tasksCmd.Flags().StringVar(&tasksStatus, "status", "", i18n.T("cmd.ai.tasks.flag.status"))
tasksCmd.Flags().StringVar(&tasksPriority, "priority", "", i18n.T("cmd.ai.tasks.flag.priority"))
tasksCmd.Flags().StringVar(&tasksLabels, "labels", "", i18n.T("cmd.ai.tasks.flag.labels"))
tasksCmd.Flags().IntVar(&tasksLimit, "limit", 20, i18n.T("cmd.ai.tasks.flag.limit"))
tasksCmd.Flags().StringVar(&tasksProject, "project", "", i18n.T("cmd.ai.tasks.flag.project"))
// task command flags
taskCmd.Flags().BoolVar(&taskAutoSelect, "auto", false, i18n.T("cmd.ai.task.flag.auto"))
taskCmd.Flags().BoolVar(&taskClaim, "claim", false, i18n.T("cmd.ai.task.flag.claim"))
taskCmd.Flags().BoolVar(&taskShowContext, "context", false, i18n.T("cmd.ai.task.flag.context"))
}
func addTasksCommand(parent *cli.Command) {
initTasksFlags()
parent.AddCommand(tasksCmd)
}
func addTaskCommand(parent *cli.Command) {
parent.AddCommand(taskCmd)
}
func printTaskList(tasks []agentic.Task) {
cli.Print("\n%s\n\n", i18n.T("cmd.ai.tasks.found", map[string]interface{}{"Count": len(tasks)}))
for _, task := range tasks {
id := taskIDStyle.Render(task.ID)
title := taskTitleStyle.Render(truncate(task.Title, 50))
priority := formatTaskPriority(task.Priority)
status := formatTaskStatus(task.Status)
line := cli.Sprintf(" %s %s %s %s", id, priority, status, title)
if len(task.Labels) > 0 {
labels := taskLabelStyle.Render("[" + strings.Join(task.Labels, ", ") + "]")
line += " " + labels
}
cli.Text(line)
}
cli.Blank()
cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.ai.tasks.hint")))
}
func printTaskDetails(task *agentic.Task) {
cli.Blank()
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.ai.label.id")), taskIDStyle.Render(task.ID))
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.ai.label.title")), taskTitleStyle.Render(task.Title))
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.ai.label.priority")), formatTaskPriority(task.Priority))
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("status")), formatTaskStatus(task.Status))
if task.Project != "" {
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("project")), task.Project)
}
if len(task.Labels) > 0 {
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.ai.label.labels")), taskLabelStyle.Render(strings.Join(task.Labels, ", ")))
}
if task.ClaimedBy != "" {
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.ai.label.claimed_by")), task.ClaimedBy)
}
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.ai.label.created")), formatAge(task.CreatedAt))
cli.Blank()
cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.ai.label.description")))
cli.Text(task.Description)
if len(task.Files) > 0 {
cli.Blank()
cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.ai.label.related_files")))
for _, f := range task.Files {
cli.Print(" - %s\n", f)
}
}
if len(task.Dependencies) > 0 {
cli.Blank()
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.ai.label.blocked_by")), strings.Join(task.Dependencies, ", "))
}
}
func formatTaskPriority(p agentic.TaskPriority) string {
switch p {
case agentic.PriorityCritical:
return taskPriorityHighStyle.Render("[" + i18n.T("cmd.ai.priority.critical") + "]")
case agentic.PriorityHigh:
return taskPriorityHighStyle.Render("[" + i18n.T("cmd.ai.priority.high") + "]")
case agentic.PriorityMedium:
return taskPriorityMediumStyle.Render("[" + i18n.T("cmd.ai.priority.medium") + "]")
case agentic.PriorityLow:
return taskPriorityLowStyle.Render("[" + i18n.T("cmd.ai.priority.low") + "]")
default:
return dimStyle.Render("[" + string(p) + "]")
}
}
func formatTaskStatus(s agentic.TaskStatus) string {
switch s {
case agentic.StatusPending:
return taskStatusPendingStyle.Render(i18n.T("cmd.ai.status.pending"))
case agentic.StatusInProgress:
return taskStatusInProgressStyle.Render(i18n.T("cmd.ai.status.in_progress"))
case agentic.StatusCompleted:
return taskStatusCompletedStyle.Render(i18n.T("cmd.ai.status.completed"))
case agentic.StatusBlocked:
return taskStatusBlockedStyle.Render(i18n.T("cmd.ai.status.blocked"))
default:
return dimStyle.Render(string(s))
}
}

131
cmd/ai/cmd_updates.go Normal file
View file

@ -0,0 +1,131 @@
// cmd_updates.go implements task update and completion commands.
package ai
import (
"context"
"time"
"forge.lthn.ai/core/go/pkg/agentic"
"forge.lthn.ai/core/go/pkg/ai"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
// task:update command flags
var (
taskUpdateStatus string
taskUpdateProgress int
taskUpdateNotes string
)
// task:complete command flags
var (
taskCompleteOutput string
taskCompleteFailed bool
taskCompleteErrorMsg string
)
var taskUpdateCmd = &cli.Command{
Use: "task:update [task-id]",
Short: i18n.T("cmd.ai.task_update.short"),
Long: i18n.T("cmd.ai.task_update.long"),
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
taskID := args[0]
if taskUpdateStatus == "" && taskUpdateProgress == 0 && taskUpdateNotes == "" {
return cli.Err("%s", i18n.T("cmd.ai.task_update.flag_required"))
}
cfg, err := agentic.LoadConfig("")
if err != nil {
return cli.WrapVerb(err, "load", "config")
}
client := agentic.NewClientFromConfig(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
update := agentic.TaskUpdate{
Progress: taskUpdateProgress,
Notes: taskUpdateNotes,
}
if taskUpdateStatus != "" {
update.Status = agentic.TaskStatus(taskUpdateStatus)
}
if err := client.UpdateTask(ctx, taskID, update); err != nil {
return cli.WrapVerb(err, "update", "task")
}
cli.Print("%s %s\n", successStyle.Render(">>"), i18n.T("i18n.done.update", "task"))
return nil
},
}
var taskCompleteCmd = &cli.Command{
Use: "task:complete [task-id]",
Short: i18n.T("cmd.ai.task_complete.short"),
Long: i18n.T("cmd.ai.task_complete.long"),
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
taskID := args[0]
cfg, err := agentic.LoadConfig("")
if err != nil {
return cli.WrapVerb(err, "load", "config")
}
client := agentic.NewClientFromConfig(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result := agentic.TaskResult{
Success: !taskCompleteFailed,
Output: taskCompleteOutput,
ErrorMessage: taskCompleteErrorMsg,
}
if err := client.CompleteTask(ctx, taskID, result); err != nil {
return cli.WrapVerb(err, "complete", "task")
}
// Record task completion event
_ = ai.Record(ai.Event{
Type: "task.completed",
AgentID: cfg.AgentID,
Data: map[string]any{"task_id": taskID, "success": !taskCompleteFailed},
})
if taskCompleteFailed {
cli.Print("%s %s\n", errorStyle.Render(">>"), i18n.T("cmd.ai.task_complete.failed", map[string]interface{}{"ID": taskID}))
} else {
cli.Print("%s %s\n", successStyle.Render(">>"), i18n.T("i18n.done.complete", "task"))
}
return nil
},
}
func initUpdatesFlags() {
// task:update command flags
taskUpdateCmd.Flags().StringVar(&taskUpdateStatus, "status", "", i18n.T("cmd.ai.task_update.flag.status"))
taskUpdateCmd.Flags().IntVar(&taskUpdateProgress, "progress", 0, i18n.T("cmd.ai.task_update.flag.progress"))
taskUpdateCmd.Flags().StringVar(&taskUpdateNotes, "notes", "", i18n.T("cmd.ai.task_update.flag.notes"))
// task:complete command flags
taskCompleteCmd.Flags().StringVar(&taskCompleteOutput, "output", "", i18n.T("cmd.ai.task_complete.flag.output"))
taskCompleteCmd.Flags().BoolVar(&taskCompleteFailed, "failed", false, i18n.T("cmd.ai.task_complete.flag.failed"))
taskCompleteCmd.Flags().StringVar(&taskCompleteErrorMsg, "error", "", i18n.T("cmd.ai.task_complete.flag.error"))
}
func addTaskUpdateCommand(parent *cli.Command) {
initUpdatesFlags()
parent.AddCommand(taskUpdateCmd)
}
func addTaskCompleteCommand(parent *cli.Command) {
parent.AddCommand(taskCompleteCmd)
}

View file

@ -0,0 +1,49 @@
package ai
import (
"context"
"forge.lthn.ai/core/go/pkg/log"
"forge.lthn.ai/core/go/pkg/ratelimit"
)
// executeWithRateLimit wraps an agent execution with rate limiting logic.
// It estimates token usage, waits for capacity, executes the runner, and records usage.
func executeWithRateLimit(ctx context.Context, model, prompt string, runner func() (bool, int, error)) (bool, int, error) {
rl, err := ratelimit.New()
if err != nil {
log.Warn("Failed to initialize rate limiter, proceeding without limits", "error", err)
return runner()
}
if err := rl.Load(); err != nil {
log.Warn("Failed to load rate limit state", "error", err)
}
// Estimate tokens from prompt length (1 token ≈ 4 chars)
estTokens := len(prompt) / 4
if estTokens == 0 {
estTokens = 1
}
log.Info("Checking rate limits", "model", model, "est_tokens", estTokens)
if err := rl.WaitForCapacity(ctx, model, estTokens); err != nil {
return false, -1, err
}
success, exitCode, runErr := runner()
// Record usage with conservative output estimate (actual tokens unknown from shell runner).
outputEst := estTokens / 10
if outputEst < 50 {
outputEst = 50
}
rl.RecordUsage(model, estTokens, outputEst)
if err := rl.Persist(); err != nil {
log.Warn("Failed to persist rate limit state", "error", err)
}
return success, exitCode, runErr
}

112
cmd/collect/cmd.go Normal file
View file

@ -0,0 +1,112 @@
package collect
import (
"fmt"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/collect"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io"
)
func init() {
cli.RegisterCommands(AddCollectCommands)
}
// Style aliases from shared package
var (
dimStyle = cli.DimStyle
successStyle = cli.SuccessStyle
errorStyle = cli.ErrorStyle
)
// Shared flags across all collect subcommands
var (
collectOutputDir string
collectVerbose bool
collectDryRun bool
)
// AddCollectCommands registers the 'collect' command and all subcommands.
func AddCollectCommands(root *cli.Command) {
collectCmd := &cli.Command{
Use: "collect",
Short: i18n.T("cmd.collect.short"),
Long: i18n.T("cmd.collect.long"),
}
// Persistent flags shared across subcommands
cli.PersistentStringFlag(collectCmd, &collectOutputDir, "output", "o", "./collect", i18n.T("cmd.collect.flag.output"))
cli.PersistentBoolFlag(collectCmd, &collectVerbose, "verbose", "v", false, i18n.T("common.flag.verbose"))
cli.PersistentBoolFlag(collectCmd, &collectDryRun, "dry-run", "", false, i18n.T("cmd.collect.flag.dry_run"))
root.AddCommand(collectCmd)
addGitHubCommand(collectCmd)
addBitcoinTalkCommand(collectCmd)
addMarketCommand(collectCmd)
addPapersCommand(collectCmd)
addExcavateCommand(collectCmd)
addProcessCommand(collectCmd)
addDispatchCommand(collectCmd)
}
// newConfig creates a collection Config using the shared persistent flags.
// It uses io.Local for real filesystem access rather than the mock medium.
func newConfig() *collect.Config {
cfg := collect.NewConfigWithMedium(io.Local, collectOutputDir)
cfg.Verbose = collectVerbose
cfg.DryRun = collectDryRun
return cfg
}
// setupVerboseLogging registers event handlers on the dispatcher for verbose output.
func setupVerboseLogging(cfg *collect.Config) {
if !cfg.Verbose {
return
}
cfg.Dispatcher.On(collect.EventStart, func(e collect.Event) {
cli.Print("%s %s\n", dimStyle.Render("[start]"), e.Message)
})
cfg.Dispatcher.On(collect.EventProgress, func(e collect.Event) {
cli.Print("%s %s\n", dimStyle.Render("[progress]"), e.Message)
})
cfg.Dispatcher.On(collect.EventItem, func(e collect.Event) {
cli.Print("%s %s\n", dimStyle.Render("[item]"), e.Message)
})
cfg.Dispatcher.On(collect.EventError, func(e collect.Event) {
cli.Print("%s %s\n", errorStyle.Render("[error]"), e.Message)
})
cfg.Dispatcher.On(collect.EventComplete, func(e collect.Event) {
cli.Print("%s %s\n", successStyle.Render("[complete]"), e.Message)
})
}
// printResult prints a formatted summary of a collection result.
func printResult(result *collect.Result) {
if result == nil {
return
}
if result.Items > 0 {
cli.Success(fmt.Sprintf("Collected %d items from %s", result.Items, result.Source))
} else {
cli.Dim(fmt.Sprintf("No items collected from %s", result.Source))
}
if result.Skipped > 0 {
cli.Dim(fmt.Sprintf(" Skipped: %d", result.Skipped))
}
if result.Errors > 0 {
cli.Warn(fmt.Sprintf(" Errors: %d", result.Errors))
}
if collectVerbose && len(result.Files) > 0 {
cli.Dim(fmt.Sprintf(" Files: %d", len(result.Files)))
for _, f := range result.Files {
cli.Print(" %s\n", dimStyle.Render(f))
}
}
}

View file

@ -0,0 +1,64 @@
package collect
import (
"context"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/collect"
"forge.lthn.ai/core/go/pkg/i18n"
)
// BitcoinTalk command flags
var bitcointalkPages int
// addBitcoinTalkCommand adds the 'bitcointalk' subcommand to the collect parent.
func addBitcoinTalkCommand(parent *cli.Command) {
btcCmd := &cli.Command{
Use: "bitcointalk <topic-id|url>",
Short: i18n.T("cmd.collect.bitcointalk.short"),
Long: i18n.T("cmd.collect.bitcointalk.long"),
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
return runBitcoinTalk(args[0])
},
}
cli.IntFlag(btcCmd, &bitcointalkPages, "pages", "p", 0, i18n.T("cmd.collect.bitcointalk.flag.pages"))
parent.AddCommand(btcCmd)
}
func runBitcoinTalk(target string) error {
var topicID, url string
// Determine if argument is a URL or topic ID
if strings.HasPrefix(target, "http") {
url = target
} else {
topicID = target
}
cfg := newConfig()
setupVerboseLogging(cfg)
collector := &collect.BitcoinTalkCollector{
TopicID: topicID,
URL: url,
Pages: bitcointalkPages,
}
if cfg.DryRun {
cli.Info("Dry run: would collect from BitcoinTalk topic " + target)
return nil
}
ctx := context.Background()
result, err := collector.Collect(ctx, cfg)
if err != nil {
return cli.Wrap(err, "bitcointalk collection failed")
}
printResult(result)
return nil
}

130
cmd/collect/cmd_dispatch.go Normal file
View file

@ -0,0 +1,130 @@
package collect
import (
"fmt"
"time"
"forge.lthn.ai/core/go/pkg/cli"
collectpkg "forge.lthn.ai/core/go/pkg/collect"
"forge.lthn.ai/core/go/pkg/i18n"
)
// addDispatchCommand adds the 'dispatch' subcommand to the collect parent.
func addDispatchCommand(parent *cli.Command) {
dispatchCmd := &cli.Command{
Use: "dispatch <event>",
Short: i18n.T("cmd.collect.dispatch.short"),
Long: i18n.T("cmd.collect.dispatch.long"),
Args: cli.MinimumNArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
return runDispatch(args[0])
},
}
// Add hooks subcommand group
hooksCmd := &cli.Command{
Use: "hooks",
Short: i18n.T("cmd.collect.dispatch.hooks.short"),
}
addHooksListCommand(hooksCmd)
addHooksRegisterCommand(hooksCmd)
dispatchCmd.AddCommand(hooksCmd)
parent.AddCommand(dispatchCmd)
}
func runDispatch(eventType string) error {
cfg := newConfig()
setupVerboseLogging(cfg)
// Validate event type
switch eventType {
case collectpkg.EventStart,
collectpkg.EventProgress,
collectpkg.EventItem,
collectpkg.EventError,
collectpkg.EventComplete:
// Valid event type
default:
return cli.Err("unknown event type: %s (valid: start, progress, item, error, complete)", eventType)
}
event := collectpkg.Event{
Type: eventType,
Source: "cli",
Message: fmt.Sprintf("Manual dispatch of %s event", eventType),
Time: time.Now(),
}
cfg.Dispatcher.Emit(event)
cli.Success(fmt.Sprintf("Dispatched %s event", eventType))
return nil
}
// addHooksListCommand adds the 'hooks list' subcommand.
func addHooksListCommand(parent *cli.Command) {
listCmd := &cli.Command{
Use: "list",
Short: i18n.T("cmd.collect.dispatch.hooks.list.short"),
RunE: func(cmd *cli.Command, args []string) error {
return runHooksList()
},
}
parent.AddCommand(listCmd)
}
func runHooksList() error {
eventTypes := []string{
collectpkg.EventStart,
collectpkg.EventProgress,
collectpkg.EventItem,
collectpkg.EventError,
collectpkg.EventComplete,
}
table := cli.NewTable("Event", "Status")
for _, et := range eventTypes {
table.AddRow(et, dimStyle.Render("no hooks registered"))
}
cli.Blank()
cli.Print("%s\n\n", cli.HeaderStyle.Render("Registered Hooks"))
table.Render()
cli.Blank()
return nil
}
// addHooksRegisterCommand adds the 'hooks register' subcommand.
func addHooksRegisterCommand(parent *cli.Command) {
registerCmd := &cli.Command{
Use: "register <event> <command>",
Short: i18n.T("cmd.collect.dispatch.hooks.register.short"),
Args: cli.ExactArgs(2),
RunE: func(cmd *cli.Command, args []string) error {
return runHooksRegister(args[0], args[1])
},
}
parent.AddCommand(registerCmd)
}
func runHooksRegister(eventType, command string) error {
// Validate event type
switch eventType {
case collectpkg.EventStart,
collectpkg.EventProgress,
collectpkg.EventItem,
collectpkg.EventError,
collectpkg.EventComplete:
// Valid
default:
return cli.Err("unknown event type: %s (valid: start, progress, item, error, complete)", eventType)
}
cli.Success(fmt.Sprintf("Registered hook for %s: %s", eventType, command))
return nil
}

103
cmd/collect/cmd_excavate.go Normal file
View file

@ -0,0 +1,103 @@
package collect
import (
"context"
"fmt"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/collect"
"forge.lthn.ai/core/go/pkg/i18n"
)
// Excavate command flags
var (
excavateScanOnly bool
excavateResume bool
)
// addExcavateCommand adds the 'excavate' subcommand to the collect parent.
func addExcavateCommand(parent *cli.Command) {
excavateCmd := &cli.Command{
Use: "excavate <project>",
Short: i18n.T("cmd.collect.excavate.short"),
Long: i18n.T("cmd.collect.excavate.long"),
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
return runExcavate(args[0])
},
}
cli.BoolFlag(excavateCmd, &excavateScanOnly, "scan-only", "", false, i18n.T("cmd.collect.excavate.flag.scan_only"))
cli.BoolFlag(excavateCmd, &excavateResume, "resume", "r", false, i18n.T("cmd.collect.excavate.flag.resume"))
parent.AddCommand(excavateCmd)
}
func runExcavate(project string) error {
cfg := newConfig()
setupVerboseLogging(cfg)
// Load state for resume
if excavateResume {
if err := cfg.State.Load(); err != nil {
return cli.Wrap(err, "failed to load collection state")
}
}
// Build collectors for the project
collectors := buildProjectCollectors(project)
if len(collectors) == 0 {
return cli.Err("no collectors configured for project: %s", project)
}
excavator := &collect.Excavator{
Collectors: collectors,
ScanOnly: excavateScanOnly,
Resume: excavateResume,
}
if cfg.DryRun {
cli.Info(fmt.Sprintf("Dry run: would excavate project %s with %d collectors", project, len(collectors)))
for _, c := range collectors {
cli.Dim(fmt.Sprintf(" - %s", c.Name()))
}
return nil
}
ctx := context.Background()
result, err := excavator.Run(ctx, cfg)
if err != nil {
return cli.Wrap(err, "excavation failed")
}
// Save state for future resume
if err := cfg.State.Save(); err != nil {
cli.Warnf("Failed to save state: %v", err)
}
printResult(result)
return nil
}
// buildProjectCollectors creates collectors based on the project name.
// This maps known project names to their collector configurations.
func buildProjectCollectors(project string) []collect.Collector {
switch project {
case "bitcoin":
return []collect.Collector{
&collect.GitHubCollector{Org: "bitcoin", Repo: "bitcoin"},
&collect.MarketCollector{CoinID: "bitcoin", Historical: true},
}
case "ethereum":
return []collect.Collector{
&collect.GitHubCollector{Org: "ethereum", Repo: "go-ethereum"},
&collect.MarketCollector{CoinID: "ethereum", Historical: true},
&collect.PapersCollector{Source: "all", Query: "ethereum"},
}
default:
// Treat unknown projects as GitHub org/repo
return []collect.Collector{
&collect.GitHubCollector{Org: project},
}
}
}

78
cmd/collect/cmd_github.go Normal file
View file

@ -0,0 +1,78 @@
package collect
import (
"context"
"strings"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/collect"
"forge.lthn.ai/core/go/pkg/i18n"
)
// GitHub command flags
var (
githubOrg bool
githubIssuesOnly bool
githubPRsOnly bool
)
// addGitHubCommand adds the 'github' subcommand to the collect parent.
func addGitHubCommand(parent *cli.Command) {
githubCmd := &cli.Command{
Use: "github <org/repo>",
Short: i18n.T("cmd.collect.github.short"),
Long: i18n.T("cmd.collect.github.long"),
Args: cli.MinimumNArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
return runGitHub(args[0])
},
}
cli.BoolFlag(githubCmd, &githubOrg, "org", "", false, i18n.T("cmd.collect.github.flag.org"))
cli.BoolFlag(githubCmd, &githubIssuesOnly, "issues-only", "", false, i18n.T("cmd.collect.github.flag.issues_only"))
cli.BoolFlag(githubCmd, &githubPRsOnly, "prs-only", "", false, i18n.T("cmd.collect.github.flag.prs_only"))
parent.AddCommand(githubCmd)
}
func runGitHub(target string) error {
if githubIssuesOnly && githubPRsOnly {
return cli.Err("--issues-only and --prs-only are mutually exclusive")
}
// Parse org/repo argument
var org, repo string
if strings.Contains(target, "/") {
parts := strings.SplitN(target, "/", 2)
org = parts[0]
repo = parts[1]
} else if githubOrg {
org = target
} else {
return cli.Err("argument must be in org/repo format, or use --org for organisation-wide collection")
}
cfg := newConfig()
setupVerboseLogging(cfg)
collector := &collect.GitHubCollector{
Org: org,
Repo: repo,
IssuesOnly: githubIssuesOnly,
PRsOnly: githubPRsOnly,
}
if cfg.DryRun {
cli.Info("Dry run: would collect from GitHub " + target)
return nil
}
ctx := context.Background()
result, err := collector.Collect(ctx, cfg)
if err != nil {
return cli.Wrap(err, "github collection failed")
}
printResult(result)
return nil
}

58
cmd/collect/cmd_market.go Normal file
View file

@ -0,0 +1,58 @@
package collect
import (
"context"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/collect"
"forge.lthn.ai/core/go/pkg/i18n"
)
// Market command flags
var (
marketHistorical bool
marketFromDate string
)
// addMarketCommand adds the 'market' subcommand to the collect parent.
func addMarketCommand(parent *cli.Command) {
marketCmd := &cli.Command{
Use: "market <coin>",
Short: i18n.T("cmd.collect.market.short"),
Long: i18n.T("cmd.collect.market.long"),
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
return runMarket(args[0])
},
}
cli.BoolFlag(marketCmd, &marketHistorical, "historical", "H", false, i18n.T("cmd.collect.market.flag.historical"))
cli.StringFlag(marketCmd, &marketFromDate, "from", "f", "", i18n.T("cmd.collect.market.flag.from"))
parent.AddCommand(marketCmd)
}
func runMarket(coinID string) error {
cfg := newConfig()
setupVerboseLogging(cfg)
collector := &collect.MarketCollector{
CoinID: coinID,
Historical: marketHistorical,
FromDate: marketFromDate,
}
if cfg.DryRun {
cli.Info("Dry run: would collect market data for " + coinID)
return nil
}
ctx := context.Background()
result, err := collector.Collect(ctx, cfg)
if err != nil {
return cli.Wrap(err, "market collection failed")
}
printResult(result)
return nil
}

63
cmd/collect/cmd_papers.go Normal file
View file

@ -0,0 +1,63 @@
package collect
import (
"context"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/collect"
"forge.lthn.ai/core/go/pkg/i18n"
)
// Papers command flags
var (
papersSource string
papersCategory string
papersQuery string
)
// addPapersCommand adds the 'papers' subcommand to the collect parent.
func addPapersCommand(parent *cli.Command) {
papersCmd := &cli.Command{
Use: "papers",
Short: i18n.T("cmd.collect.papers.short"),
Long: i18n.T("cmd.collect.papers.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runPapers()
},
}
cli.StringFlag(papersCmd, &papersSource, "source", "s", "all", i18n.T("cmd.collect.papers.flag.source"))
cli.StringFlag(papersCmd, &papersCategory, "category", "c", "", i18n.T("cmd.collect.papers.flag.category"))
cli.StringFlag(papersCmd, &papersQuery, "query", "q", "", i18n.T("cmd.collect.papers.flag.query"))
parent.AddCommand(papersCmd)
}
func runPapers() error {
if papersQuery == "" {
return cli.Err("--query (-q) is required")
}
cfg := newConfig()
setupVerboseLogging(cfg)
collector := &collect.PapersCollector{
Source: papersSource,
Category: papersCategory,
Query: papersQuery,
}
if cfg.DryRun {
cli.Info("Dry run: would collect papers from " + papersSource)
return nil
}
ctx := context.Background()
result, err := collector.Collect(ctx, cfg)
if err != nil {
return cli.Wrap(err, "papers collection failed")
}
printResult(result)
return nil
}

View file

@ -0,0 +1,48 @@
package collect
import (
"context"
"forge.lthn.ai/core/go/pkg/cli"
"forge.lthn.ai/core/go/pkg/collect"
"forge.lthn.ai/core/go/pkg/i18n"
)
// addProcessCommand adds the 'process' subcommand to the collect parent.
func addProcessCommand(parent *cli.Command) {
processCmd := &cli.Command{
Use: "process <source> <dir>",
Short: i18n.T("cmd.collect.process.short"),
Long: i18n.T("cmd.collect.process.long"),
Args: cli.ExactArgs(2),
RunE: func(cmd *cli.Command, args []string) error {
return runProcess(args[0], args[1])
},
}
parent.AddCommand(processCmd)
}
func runProcess(source, dir string) error {
cfg := newConfig()
setupVerboseLogging(cfg)
processor := &collect.Processor{
Source: source,
Dir: dir,
}
if cfg.DryRun {
cli.Info("Dry run: would process " + source + " data in " + dir)
return nil
}
ctx := context.Background()
result, err := processor.Process(ctx, cfg)
if err != nil {
return cli.Wrap(err, "processing failed")
}
printResult(result)
return nil
}

602
cmd/community/index.html Normal file
View file

@ -0,0 +1,602 @@
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lethean Community — Build Trust Through Code</title>
<meta name="description" content="An open source community where developers earn functional trust by fixing real bugs. BugSETI by Lethean.io — SETI@home for code.">
<link rel="canonical" href="https://lthn.community">
<!-- Open Graph -->
<meta property="og:title" content="Lethean Community — Build Trust Through Code">
<meta property="og:description" content="An open source community where developers earn functional trust by fixing real bugs.">
<meta property="og:url" content="https://lthn.community">
<meta property="og:type" content="website">
<!-- Tailwind CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
lethean: {
950: '#070a0f',
900: '#0d1117',
800: '#161b22',
700: '#21262d',
600: '#30363d',
500: '#484f58',
400: '#8b949e',
300: '#c9d1d9',
200: '#e6edf3',
},
cyan: {
400: '#40c1c5',
500: '#2da8ac',
},
blue: {
400: '#58a6ff',
500: '#4A90E2',
600: '#357ABD',
},
},
fontFamily: {
display: ['"DM Sans"', 'system-ui', 'sans-serif'],
mono: ['"JetBrains Mono"', 'ui-monospace', 'SFMono-Regular', 'monospace'],
},
}
}
}
</script>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,300;1,9..40,400&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<style>
body {
font-family: 'DM Sans', system-ui, sans-serif;
background: #070a0f;
color: #c9d1d9;
}
/* Grain overlay */
body::before {
content: '';
position: fixed;
inset: 0;
z-index: 50;
pointer-events: none;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* Cursor glow */
.glow-cursor {
position: fixed;
width: 600px;
height: 600px;
border-radius: 50%;
pointer-events: none;
z-index: 1;
background: radial-gradient(circle, rgba(64,193,197,0.06) 0%, transparent 70%);
transform: translate(-50%, -50%);
transition: opacity 0.3s;
}
/* Typing cursor blink */
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
.cursor-blink::after {
content: '▊';
animation: blink 1s infinite;
color: #40c1c5;
margin-left: 2px;
}
/* Fade-in on scroll */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-up {
opacity: 0;
animation: fadeUp 0.6s ease-out forwards;
}
.fade-up-d1 { animation-delay: 0.1s; }
.fade-up-d2 { animation-delay: 0.2s; }
.fade-up-d3 { animation-delay: 0.3s; }
.fade-up-d4 { animation-delay: 0.4s; }
.fade-up-d5 { animation-delay: 0.5s; }
.fade-up-d6 { animation-delay: 0.6s; }
/* Terminal-style section divider */
.terminal-line::before {
content: '$ ';
color: #40c1c5;
font-family: 'JetBrains Mono', monospace;
}
/* Gradient border effect */
.gradient-border {
position: relative;
}
.gradient-border::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, #40c1c5, #4A90E2, #40c1c5);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
/* Soft glow for hero text */
.text-glow {
text-shadow: 0 0 80px rgba(64,193,197,0.3), 0 0 32px rgba(64,193,197,0.1);
}
/* Stats counter animation */
@keyframes countUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* Link hover effect */
.link-underline {
position: relative;
}
.link-underline::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 1px;
background: #40c1c5;
transition: width 0.3s ease;
}
.link-underline:hover::after {
width: 100%;
}
</style>
</head>
<body class="antialiased relative overflow-x-hidden">
<!-- Cursor glow follower -->
<div class="glow-cursor hidden lg:block" id="glowCursor"></div>
<!-- ─────────────────────────────────────────────── -->
<!-- NAV -->
<!-- ─────────────────────────────────────────────── -->
<nav class="fixed top-0 inset-x-0 z-40 backdrop-blur-xl bg-lethean-950/80 border-b border-lethean-600/30">
<div class="max-w-6xl mx-auto px-6 h-14 flex items-center justify-between">
<a href="/" class="flex items-center gap-2.5 group">
<span class="text-cyan-400 font-mono text-sm font-medium tracking-tight">lthn</span>
<span class="text-lethean-500 font-mono text-xs">/</span>
<span class="text-lethean-300 text-sm font-medium">community</span>
</a>
<div class="flex items-center gap-6 text-sm">
<a href="#how-it-works" class="text-lethean-400 hover:text-lethean-200 transition-colors link-underline">How it works</a>
<a href="#ecosystem" class="text-lethean-400 hover:text-lethean-200 transition-colors link-underline">Ecosystem</a>
<a href="https://forge.lthn.ai/core/cli" target="_blank" rel="noopener" class="text-lethean-400 hover:text-lethean-200 transition-colors link-underline">GitHub</a>
<a href="#join" class="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-md bg-cyan-400/10 text-cyan-400 border border-cyan-400/20 hover:bg-cyan-400/20 hover:border-cyan-400/30 transition-all text-sm font-medium">
Get BugSETI
</a>
</div>
</div>
</nav>
<!-- ─────────────────────────────────────────────── -->
<!-- HERO -->
<!-- ─────────────────────────────────────────────── -->
<section class="relative min-h-screen flex items-center justify-center pt-14">
<!-- Background grid -->
<div class="absolute inset-0" style="background-image: linear-gradient(rgba(48,54,61,0.15) 1px, transparent 1px), linear-gradient(90deg, rgba(48,54,61,0.15) 1px, transparent 1px); background-size: 64px 64px;"></div>
<!-- Radial fade -->
<div class="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_20%,#070a0f_70%)]"></div>
<div class="relative z-10 max-w-4xl mx-auto px-6 text-center">
<!-- Badge -->
<div class="fade-up inline-flex items-center gap-2 px-3 py-1 rounded-full bg-lethean-800/80 border border-lethean-600/40 text-xs font-mono text-lethean-400 mb-8">
<span class="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse"></span>
BugSETI by Lethean.io
</div>
<!-- Headline -->
<h1 class="fade-up fade-up-d1 text-5xl sm:text-6xl lg:text-7xl font-bold tracking-tight leading-[1.08] mb-6">
<span class="text-lethean-200">Build trust</span><br>
<span class="text-glow text-cyan-400">through code</span>
</h1>
<!-- Subheadline -->
<p class="fade-up fade-up-d2 text-lg sm:text-xl text-lethean-400 max-w-2xl mx-auto mb-10 leading-relaxed">
An open source community where every commit, review, and pull request
builds your reputation. Like SETI@home, but for fixing real bugs in real projects.
</p>
<!-- Terminal preview -->
<div class="fade-up fade-up-d3 max-w-lg mx-auto mb-10">
<div class="gradient-border rounded-lg overflow-hidden">
<div class="bg-lethean-900 rounded-lg">
<div class="flex items-center gap-1.5 px-4 py-2.5 border-b border-lethean-700/50">
<span class="w-2.5 h-2.5 rounded-full bg-lethean-600/60"></span>
<span class="w-2.5 h-2.5 rounded-full bg-lethean-600/60"></span>
<span class="w-2.5 h-2.5 rounded-full bg-lethean-600/60"></span>
<span class="ml-3 text-xs font-mono text-lethean-500">~</span>
</div>
<div class="px-4 py-4 text-left font-mono text-sm leading-relaxed">
<div class="text-lethean-400"><span class="text-cyan-400">$</span> bugseti start</div>
<div class="text-lethean-500 mt-1">⠋ Fetching issues from 42 OSS repos...</div>
<div class="text-green-400/80 mt-1">✓ 7 beginner-friendly issues queued</div>
<div class="text-green-400/80">✓ AI context prepared for each issue</div>
<div class="text-lethean-300 mt-1">Ready. Fix bugs. Build trust. <span class="cursor-blink"></span></div>
</div>
</div>
</div>
</div>
<!-- CTAs -->
<div class="fade-up fade-up-d4 flex flex-col sm:flex-row items-center justify-center gap-3">
<a href="#join" class="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-cyan-400 text-lethean-950 font-semibold text-sm hover:bg-cyan-400/90 transition-all shadow-lg shadow-cyan-400/10">
Download BugSETI
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/></svg>
</a>
<a href="https://forge.lthn.ai/core/cli" target="_blank" rel="noopener" class="inline-flex items-center gap-2 px-6 py-3 rounded-lg border border-lethean-600/50 text-lethean-300 font-medium text-sm hover:bg-lethean-800/50 hover:border-lethean-500/50 transition-all">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
View Source
</a>
</div>
</div>
</section>
<!-- ─────────────────────────────────────────────── -->
<!-- HOW IT WORKS -->
<!-- ─────────────────────────────────────────────── -->
<section id="how-it-works" class="relative py-32">
<div class="max-w-5xl mx-auto px-6">
<div class="text-center mb-20">
<p class="font-mono text-xs text-cyan-400 tracking-widest uppercase mb-3">How it works</p>
<h2 class="text-3xl sm:text-4xl font-bold text-lethean-200 mb-4">From install to impact</h2>
<p class="text-lethean-400 max-w-xl mx-auto">BugSETI runs in your system tray. It finds issues, prepares context, and gets out of your way. You write code. The community remembers.</p>
</div>
<div class="grid md:grid-cols-3 gap-6">
<!-- Step 1 -->
<div class="group relative p-6 rounded-xl bg-lethean-900/50 border border-lethean-700/30 hover:border-cyan-400/20 transition-all duration-300">
<div class="flex items-center gap-3 mb-4">
<span class="flex items-center justify-center w-8 h-8 rounded-md bg-cyan-400/10 text-cyan-400 font-mono text-sm font-bold">1</span>
<h3 class="text-lethean-200 font-semibold">Install & connect</h3>
</div>
<p class="text-sm text-lethean-400 leading-relaxed mb-4">Download BugSETI, connect your GitHub account. That's your identity in the Lethean Community — one account, everywhere.</p>
<div class="font-mono text-xs text-lethean-500 bg-lethean-800/50 rounded-md px-3 py-2">
<span class="text-cyan-400/70">$</span> gh auth login<br>
<span class="text-cyan-400/70">$</span> bugseti init
</div>
</div>
<!-- Step 2 -->
<div class="group relative p-6 rounded-xl bg-lethean-900/50 border border-lethean-700/30 hover:border-cyan-400/20 transition-all duration-300">
<div class="flex items-center gap-3 mb-4">
<span class="flex items-center justify-center w-8 h-8 rounded-md bg-cyan-400/10 text-cyan-400 font-mono text-sm font-bold">2</span>
<h3 class="text-lethean-200 font-semibold">Pick an issue</h3>
</div>
<p class="text-sm text-lethean-400 leading-relaxed mb-4">BugSETI scans OSS repos for beginner-friendly issues. AI prepares context — the relevant files, similar past fixes, project conventions.</p>
<div class="font-mono text-xs text-lethean-500 bg-lethean-800/50 rounded-md px-3 py-2">
<span class="text-green-400/70"></span> 7 issues ready<br>
<span class="text-green-400/70"></span> Context seeded
</div>
</div>
<!-- Step 3 -->
<div class="group relative p-6 rounded-xl bg-lethean-900/50 border border-lethean-700/30 hover:border-cyan-400/20 transition-all duration-300">
<div class="flex items-center gap-3 mb-4">
<span class="flex items-center justify-center w-8 h-8 rounded-md bg-cyan-400/10 text-cyan-400 font-mono text-sm font-bold">3</span>
<h3 class="text-lethean-200 font-semibold">Fix & earn trust</h3>
</div>
<p class="text-sm text-lethean-400 leading-relaxed mb-4">Submit your PR. Every merged fix, every review, every contribution — it all counts. Your track record becomes your reputation.</p>
<div class="font-mono text-xs text-lethean-500 bg-lethean-800/50 rounded-md px-3 py-2">
<span class="text-green-400/70"></span> PR #247 merged<br>
<span class="text-cyan-400/70"></span> Trust updated
</div>
</div>
</div>
</div>
</section>
<!-- ─────────────────────────────────────────────── -->
<!-- WHAT YOU GET -->
<!-- ─────────────────────────────────────────────── -->
<section class="relative py-24">
<div class="max-w-5xl mx-auto px-6">
<!-- BugSETI features -->
<div class="grid lg:grid-cols-2 gap-16 items-center mb-32">
<div>
<p class="font-mono text-xs text-cyan-400 tracking-widest uppercase mb-3">The app</p>
<h2 class="text-3xl font-bold text-lethean-200 mb-4">A workbench in your tray</h2>
<p class="text-lethean-400 leading-relaxed mb-6">BugSETI lives in your system tray on macOS, Linux, and Windows. It quietly fetches issues, seeds AI context, and presents a clean workbench when you're ready to code.</p>
<div class="space-y-3 text-sm">
<div class="flex items-start gap-3">
<span class="text-cyan-400 mt-0.5 font-mono text-xs"></span>
<span class="text-lethean-300">Priority queue — issues ranked by your skills and interests</span>
</div>
<div class="flex items-start gap-3">
<span class="text-cyan-400 mt-0.5 font-mono text-xs"></span>
<span class="text-lethean-300">AI context seeding — relevant files and patterns, ready to go</span>
</div>
<div class="flex items-start gap-3">
<span class="text-cyan-400 mt-0.5 font-mono text-xs"></span>
<span class="text-lethean-300">One-click PR submission — fork, branch, commit, push</span>
</div>
<div class="flex items-start gap-3">
<span class="text-cyan-400 mt-0.5 font-mono text-xs"></span>
<span class="text-lethean-300">Stats tracking — streaks, repos contributed, PRs merged</span>
</div>
</div>
</div>
<div class="gradient-border rounded-xl overflow-hidden">
<div class="bg-lethean-900 rounded-xl p-1">
<!-- Mock app UI -->
<div class="bg-lethean-800 rounded-lg overflow-hidden">
<div class="flex items-center gap-1.5 px-3 py-2 bg-lethean-900/80 border-b border-lethean-700/30">
<span class="w-2 h-2 rounded-full bg-red-400/40"></span>
<span class="w-2 h-2 rounded-full bg-yellow-400/40"></span>
<span class="w-2 h-2 rounded-full bg-green-400/40"></span>
<span class="ml-2 text-[10px] font-mono text-lethean-500">BugSETI — Workbench</span>
</div>
<div class="p-4 space-y-3">
<!-- Mock issue card -->
<div class="p-3 rounded-md bg-lethean-900/60 border border-lethean-700/20">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-mono text-cyan-400/80">lodash/lodash#5821</span>
<span class="text-[10px] px-2 py-0.5 rounded-full bg-green-400/10 text-green-400/80 border border-green-400/20">good first issue</span>
</div>
<p class="text-xs text-lethean-300 mb-2">Fix _.merge not handling Symbol properties</p>
<div class="flex items-center gap-3 text-[10px] text-lethean-500">
<span>⭐ 58.2k</span>
<span>JavaScript</span>
<span>Context ready</span>
</div>
</div>
<!-- Mock issue card 2 -->
<div class="p-3 rounded-md bg-lethean-900/30 border border-lethean-700/10">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-mono text-lethean-500">vuejs/core#9214</span>
<span class="text-[10px] px-2 py-0.5 rounded-full bg-blue-400/10 text-blue-400/70 border border-blue-400/15">bug</span>
</div>
<p class="text-xs text-lethean-400 mb-2">Teleport target not updating on HMR</p>
<div class="flex items-center gap-3 text-[10px] text-lethean-500">
<span>⭐ 44.7k</span>
<span>TypeScript</span>
<span>Seeding...</span>
</div>
</div>
<!-- Status bar -->
<div class="flex items-center justify-between pt-2 border-t border-lethean-700/20 text-[10px] font-mono text-lethean-500">
<span>7 issues queued</span>
<span class="text-cyan-400/60">♫ dapp.fm playing</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- dapp.fm teaser -->
<div class="grid lg:grid-cols-2 gap-16 items-center">
<div class="order-2 lg:order-1">
<div class="gradient-border rounded-xl overflow-hidden">
<div class="bg-lethean-900 rounded-xl p-6">
<div class="flex items-center gap-4 mb-4">
<div class="w-12 h-12 rounded-lg bg-gradient-to-br from-cyan-400/20 to-blue-500/20 flex items-center justify-center text-cyan-400">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/></svg>
</div>
<div>
<p class="text-lethean-200 font-semibold">dapp.fm</p>
<p class="text-xs text-lethean-500">Built into BugSETI</p>
</div>
</div>
<!-- Mini player mock -->
<div class="bg-lethean-800/60 rounded-lg p-4 border border-lethean-700/20">
<div class="flex items-center gap-3 mb-3">
<div class="w-10 h-10 rounded-md bg-gradient-to-br from-purple-500/30 to-cyan-400/30 flex items-center justify-center text-xs text-lethean-400"></div>
<div class="flex-1 min-w-0">
<p class="text-xs text-lethean-200 truncate">It Feels So Good (Amnesia Mix)</p>
<p class="text-[10px] text-lethean-500">The Conductor & The Cowboy</p>
</div>
<span class="text-[10px] font-mono text-lethean-500">3:42</span>
</div>
<div class="h-1 bg-lethean-700/50 rounded-full overflow-hidden">
<div class="h-full w-2/3 bg-gradient-to-r from-cyan-400/60 to-cyan-400/30 rounded-full"></div>
</div>
</div>
<p class="text-[10px] text-lethean-500 mt-3 font-mono">Zero-trust DRM · Artists keep 95100% · ChaCha20-Poly1305</p>
</div>
</div>
</div>
<div class="order-1 lg:order-2">
<p class="font-mono text-xs text-cyan-400 tracking-widest uppercase mb-3">Built in</p>
<h2 class="text-3xl font-bold text-lethean-200 mb-4">Music while you merge</h2>
<p class="text-lethean-400 leading-relaxed mb-6">dapp.fm is a free music player built into BugSETI. Zero-trust DRM where the password is the license. Artists keep almost everything. No middlemen, no platform fees.</p>
<p class="text-sm text-lethean-400 leading-relaxed">The player is a working implementation of the Lethean protocol RFCs — encrypted, decentralised, and yours. Code, listen, contribute.</p>
<a href="https://demo.dapp.fm" target="_blank" rel="noopener" class="inline-flex items-center gap-1.5 mt-4 text-sm text-cyan-400 hover:text-cyan-400/80 transition-colors link-underline">
Try the demo
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
</div>
</div>
</div>
</section>
<!-- ─────────────────────────────────────────────── -->
<!-- ECOSYSTEM -->
<!-- ─────────────────────────────────────────────── -->
<section id="ecosystem" class="relative py-32">
<div class="max-w-5xl mx-auto px-6">
<div class="text-center mb-16">
<p class="font-mono text-xs text-cyan-400 tracking-widest uppercase mb-3">Ecosystem</p>
<h2 class="text-3xl sm:text-4xl font-bold text-lethean-200 mb-4">One identity, everywhere</h2>
<p class="text-lethean-400 max-w-xl mx-auto">Your GitHub is your Lethean identity. One name across Web2, Web3, Handshake DNS, blockchain — verified by what you've actually done.</p>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Card: Lethean Protocol -->
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Protocol</div>
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">Lethean Network</h3>
<p class="text-sm text-lethean-400 leading-relaxed">Privacy-first blockchain. Consent-gated networking via the UEPS protocol. Data sovereignty cryptographically enforced.</p>
<a href="https://lt.hn" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-3 text-xs text-cyan-400/60 hover:text-cyan-400 transition-colors">lt.hn →</a>
</div>
<!-- Card: Handshake DNS -->
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Identity</div>
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">lthn/ everywhere</h3>
<p class="text-sm text-lethean-400 leading-relaxed">Handshake TLD, .io, .ai, .community, .eth, .tron — one name that resolves across every namespace. Your DID, decentralised.</p>
<a href="https://hns.to" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-3 text-xs text-cyan-400/60 hover:text-cyan-400 transition-colors">hns.to →</a>
</div>
<!-- Card: Open Source -->
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Foundation</div>
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">EUPL-1.2</h3>
<p class="text-sm text-lethean-400 leading-relaxed">Every line is open source under the European Union Public License. 23 languages, no jurisdiction loopholes. Code stays open, forever.</p>
<a href="https://host.uk.com/oss" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-3 text-xs text-cyan-400/60 hover:text-cyan-400 transition-colors">host.uk.com/oss →</a>
</div>
<!-- Card: AI Models -->
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Coming</div>
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">lthn.ai</h3>
<p class="text-sm text-lethean-400 leading-relaxed">Open source EUPL-1.2 models up to 70B parameters. High quality, embeddable transformers for the community.</p>
<span class="inline-flex items-center gap-1 mt-3 text-xs text-lethean-500">Coming soon</span>
</div>
<!-- Card: dapp.fm -->
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Music</div>
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">dapp.fm</h3>
<p class="text-sm text-lethean-400 leading-relaxed">All-in-one publishing platform. Zero-trust DRM. Artists keep 95100%. Built on Borg encryption and LTHN rolling keys.</p>
<a href="https://demo.dapp.fm" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-3 text-xs text-cyan-400/60 hover:text-cyan-400 transition-colors">demo.dapp.fm →</a>
</div>
<!-- Card: Host UK -->
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Services</div>
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">Host UK</h3>
<p class="text-sm text-lethean-400 leading-relaxed">Infrastructure and services brand of the Lethean Community. Privacy-first hosting, analytics, trust verification, notifications.</p>
<a href="https://host.uk.com" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-3 text-xs text-cyan-400/60 hover:text-cyan-400 transition-colors">host.uk.com →</a>
</div>
</div>
</div>
</section>
<!-- ─────────────────────────────────────────────── -->
<!-- JOIN / DOWNLOAD -->
<!-- ─────────────────────────────────────────────── -->
<section id="join" class="relative py-32">
<!-- Subtle gradient bg -->
<div class="absolute inset-0 bg-gradient-to-b from-transparent via-cyan-400/[0.02] to-transparent"></div>
<div class="relative max-w-3xl mx-auto px-6 text-center">
<p class="font-mono text-xs text-cyan-400 tracking-widest uppercase mb-3">Get started</p>
<h2 class="text-3xl sm:text-4xl font-bold text-lethean-200 mb-4">Join the community</h2>
<p class="text-lethean-400 max-w-lg mx-auto mb-10">Install BugSETI. Connect your GitHub. Start contributing. Every bug you fix makes open source better — and builds a trust record that's cryptographically yours.</p>
<!-- Download buttons -->
<div class="flex flex-col sm:flex-row items-center justify-center gap-3 mb-12">
<a href="https://forge.lthn.ai/core/cli/releases" target="_blank" rel="noopener" class="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg bg-lethean-800 border border-lethean-600/40 text-lethean-200 font-medium text-sm hover:bg-lethean-700 hover:border-lethean-500/50 transition-all">
<span class="text-lg">🐧</span> Linux
</a>
<a href="https://forge.lthn.ai/core/cli/releases" target="_blank" rel="noopener" class="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg bg-lethean-800 border border-lethean-600/40 text-lethean-200 font-medium text-sm hover:bg-lethean-700 hover:border-lethean-500/50 transition-all">
<span class="text-lg">🍎</span> macOS
</a>
<a href="https://forge.lthn.ai/core/cli/releases" target="_blank" rel="noopener" class="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg bg-lethean-800 border border-lethean-600/40 text-lethean-200 font-medium text-sm hover:bg-lethean-700 hover:border-lethean-500/50 transition-all">
<span class="text-lg">🪟</span> Windows
</a>
</div>
<!-- Or just the terminal way -->
<div class="gradient-border rounded-lg overflow-hidden max-w-md mx-auto">
<div class="bg-lethean-900 rounded-lg px-5 py-3 font-mono text-sm text-left">
<span class="text-lethean-500"># or build from source</span><br>
<span class="text-cyan-400">$</span> <span class="text-lethean-300">git clone https://forge.lthn.ai/core/cli</span><br>
<span class="text-cyan-400">$</span> <span class="text-lethean-300">cd core && go build ./cmd/bugseti</span>
</div>
</div>
</div>
</section>
<!-- ─────────────────────────────────────────────── -->
<!-- FOOTER -->
<!-- ─────────────────────────────────────────────── -->
<footer class="border-t border-lethean-700/20 py-12">
<div class="max-w-5xl mx-auto px-6">
<div class="flex flex-col md:flex-row items-center justify-between gap-6">
<div class="flex items-center gap-2">
<span class="font-mono text-sm text-cyan-400">lthn</span>
<span class="text-lethean-600 font-mono text-xs">/</span>
<span class="text-lethean-400 text-sm">community</span>
</div>
<div class="flex items-center gap-6 text-xs text-lethean-500">
<a href="https://github.com/host-uk" target="_blank" rel="noopener" class="hover:text-lethean-300 transition-colors">GitHub</a>
<a href="https://discord.com/invite/lethean-lthn-379876792003067906" target="_blank" rel="noopener" class="hover:text-lethean-300 transition-colors">Discord</a>
<a href="https://lethean.io" target="_blank" rel="noopener" class="hover:text-lethean-300 transition-colors">Lethean.io</a>
<a href="https://host.uk.com" target="_blank" rel="noopener" class="hover:text-lethean-300 transition-colors">Host UK</a>
</div>
<div class="text-xs text-lethean-600 font-mono">
EUPL-1.2 · Viva La OpenSource
</div>
</div>
</div>
</footer>
<!-- ─────────────────────────────────────────────── -->
<!-- JS: Cursor glow + scroll animations -->
<!-- ─────────────────────────────────────────────── -->
<script>
// Cursor glow follower
const glow = document.getElementById('glowCursor');
if (glow && window.matchMedia('(pointer: fine)').matches) {
document.addEventListener('mousemove', (e) => {
glow.style.left = e.clientX + 'px';
glow.style.top = e.clientY + 'px';
});
}
// Intersection Observer for fade-in sections
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('fade-up');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.1 });
// Observe all section headings and cards
document.querySelectorAll('section:not(:first-of-type) h2, section:not(:first-of-type) .grid > div').forEach(el => {
el.style.opacity = '0';
el.style.transform = 'translateY(24px)';
observer.observe(el);
});
</script>
</body>
</html>

View file

@ -2,6 +2,10 @@ package config
import "forge.lthn.ai/core/go/pkg/cli"
func init() {
cli.RegisterCommands(AddConfigCommands)
}
// AddConfigCommands registers the 'config' command group and all subcommands.
func AddConfigCommands(root *cli.Command) {
configCmd := cli.NewGroup("config", "Manage configuration", "")

View file

@ -0,0 +1,100 @@
# Codex Task: Core App — FrankenPHP Native Desktop App
## Context
You are working on `cmd/core-app/` inside the `host-uk/core` Go monorepo. This is a **working** native desktop application that embeds the PHP runtime (FrankenPHP) inside a Wails v3 window. A single 53MB binary runs Laravel 12 with Livewire 4, Octane worker mode, and SQLite — no Docker, no php-fpm, no nginx, no external dependencies.
**It already builds and runs.** Your job is to refine, not rebuild.
## Architecture
```
Wails v3 WebView (native window)
|
| AssetOptions.Handler → http.Handler
v
FrankenPHP (CGO, PHP 8.4 ZTS runtime)
|
| ServeHTTP() → Laravel public/index.php
v
Laravel 12 (Octane worker mode, 2 workers)
├── Livewire 4 (server-rendered reactivity)
├── SQLite (~/Library/Application Support/core-app/)
└── Native Bridge (localhost HTTP API for PHP→Go calls)
```
## Key Files
| File | Purpose |
|------|---------|
| `main.go` | Wails app entry, system tray, window config |
| `handler.go` | PHPHandler — FrankenPHP init, Octane worker mode, try_files URL resolution |
| `embed.go` | `//go:embed all:laravel` + extraction to temp dir |
| `env.go` | Persistent data dir, .env generation, APP_KEY management |
| `app_service.go` | Wails service bindings (version, data dir, window management) |
| `native_bridge.go` | PHP→Go HTTP bridge on localhost (random port) |
| `laravel/` | Full Laravel 12 skeleton (vendor excluded from git, built via `composer install`) |
## Build Requirements
- **PHP 8.4 ZTS**: `brew install shivammathur/php/php@8.4-zts`
- **Go 1.25+** with CGO enabled
- **Build tags**: `-tags nowatcher` (FrankenPHP's watcher needs libwatcher-c, skip it)
- **ZTS php-config**: Must use `/opt/homebrew/opt/php@8.4-zts/bin/php-config` (NOT the default php-config which may point to non-ZTS PHP)
```bash
# Install Laravel deps (one-time)
cd laravel && composer install --no-dev --optimize-autoloader
# Build
ZTS_PHP_CONFIG=/opt/homebrew/opt/php@8.4-zts/bin/php-config
CGO_ENABLED=1 \
CGO_CFLAGS="$($ZTS_PHP_CONFIG --includes)" \
CGO_LDFLAGS="-L/opt/homebrew/opt/php@8.4-zts/lib $($ZTS_PHP_CONFIG --ldflags) $($ZTS_PHP_CONFIG --libs)" \
go build -tags nowatcher -o ../../bin/core-app .
```
## Known Patterns & Gotchas
1. **FrankenPHP can't serve from embed.FS** — must extract to temp dir, symlink `storage/` to persistent data dir
2. **WithWorkers API (v1.5.0)**: `WithWorkers(name, fileName string, num int, env map[string]string, watch []string)` — 5 positional args, NOT variadic
3. **Worker mode needs Octane**: Workers point at `vendor/laravel/octane/bin/frankenphp-worker.php` with `APP_BASE_PATH` and `FRANKENPHP_WORKER=1` env vars
4. **Paths with spaces**: macOS `~/Library/Application Support/` has a space — ALL .env values with paths MUST be quoted
5. **URL resolution**: FrankenPHP doesn't auto-resolve `/``/index.php` — the Go handler implements try_files logic
6. **Auto-migration**: `AppServiceProvider::boot()` runs `migrate --force` wrapped in try/catch (must not fail during composer operations)
7. **Vendor dir**: Excluded from git (`.gitignore`), built at dev time via `composer install`, embedded by `//go:embed all:laravel` at build time
## Coding Standards
- **UK English**: colour, organisation, centre
- **PHP**: `declare(strict_types=1)` in every file, full type hints, PSR-12 via Pint
- **Go**: Standard Go conventions, error wrapping with `fmt.Errorf("context: %w", err)`
- **License**: EUPL-1.2
- **Testing**: Pest syntax for PHP (not PHPUnit)
## Tasks for Codex
### Priority 1: Code Quality
- [ ] Review all Go files for error handling consistency
- [ ] Ensure handler.go's try_files logic handles edge cases (double slashes, encoded paths, path traversal)
- [ ] Add Go tests for PHPHandler URL resolution (unit tests, no FrankenPHP needed)
- [ ] Add Go tests for env.go (resolveDataDir, writeEnvFile, loadOrGenerateAppKey)
### Priority 2: Laravel Polish
- [ ] Add `config/octane.php` with FrankenPHP server config
- [ ] Update welcome view to show migration status (table count from SQLite)
- [ ] Add a second Livewire component (e.g., todo list) to prove full CRUD with SQLite
- [ ] Add proper error page views (404, 500) styled to match the dark theme
### Priority 3: Build Hardening
- [ ] Verify the Taskfile.yml tasks work end-to-end (`task app:setup && task app:composer && task app:build`)
- [ ] Add `.gitignore` entries for build artifacts (`bin/core-app`, temp dirs)
- [ ] Ensure `go.work` and `go.mod` are consistent
## CRITICAL WARNINGS
- **DO NOT push to GitHub** — GitHub remotes have been removed deliberately. The host-uk org is flagged.
- **DO NOT add GitHub as a remote** — Forge (forge.lthn.io / git.lthn.ai) is the source of truth.
- **DO NOT modify files outside `cmd/core-app/`** — This is a workspace module, keep changes scoped.
- **DO NOT remove the `-tags nowatcher` build flag** — It will fail without libwatcher-c.
- **DO NOT change the PHP-ZTS path** — It must be the ZTS variant, not the default Homebrew PHP.

37
cmd/core-app/Taskfile.yml Normal file
View file

@ -0,0 +1,37 @@
version: '3'
vars:
PHP_CONFIG: /opt/homebrew/opt/php@8.4-zts/bin/php-config
CGO_CFLAGS:
sh: "{{.PHP_CONFIG}} --includes"
CGO_LDFLAGS:
sh: "echo -L/opt/homebrew/opt/php@8.4-zts/lib $({{.PHP_CONFIG}} --ldflags) $({{.PHP_CONFIG}} --libs)"
tasks:
setup:
desc: "Install PHP-ZTS build dependency"
cmds:
- brew tap shivammathur/php 2>/dev/null || true
- brew install shivammathur/php/php@8.4-zts
build:
desc: "Build core-app binary"
env:
CGO_ENABLED: "1"
CGO_CFLAGS: "{{.CGO_CFLAGS}}"
CGO_LDFLAGS: "{{.CGO_LDFLAGS}}"
cmds:
- go build -tags nowatcher -o ../../bin/core-app .
dev:
desc: "Build and run core-app"
deps: [build]
env:
DYLD_LIBRARY_PATH: "/opt/homebrew/opt/php@8.4-zts/lib"
cmds:
- ../../bin/core-app
clean:
desc: "Remove build artifacts"
cmds:
- rm -f ../../bin/core-app

View file

@ -0,0 +1,48 @@
package main
import (
"github.com/wailsapp/wails/v3/pkg/application"
)
// AppService provides native desktop capabilities to the Wails frontend.
// These methods are callable via window.go.main.AppService.{Method}()
// from any JavaScript/webview context.
type AppService struct {
app *application.App
env *AppEnvironment
}
func NewAppService(env *AppEnvironment) *AppService {
return &AppService{env: env}
}
// ServiceStartup is called by Wails when the application starts.
func (s *AppService) ServiceStartup(app *application.App) {
s.app = app
}
// GetVersion returns the application version.
func (s *AppService) GetVersion() string {
return "0.1.0"
}
// GetDataDir returns the persistent data directory path.
func (s *AppService) GetDataDir() string {
return s.env.DataDir
}
// GetDatabasePath returns the SQLite database file path.
func (s *AppService) GetDatabasePath() string {
return s.env.DatabasePath
}
// ShowWindow shows and focuses the main application window.
func (s *AppService) ShowWindow(name string) {
if s.app == nil {
return
}
if w, ok := s.app.Window.Get(name); ok {
w.Show()
w.Focus()
}
}

52
cmd/core-app/embed.go Normal file
View file

@ -0,0 +1,52 @@
package main
import (
"embed"
"fmt"
"io/fs"
"os"
"path/filepath"
)
//go:embed all:laravel
var laravelFiles embed.FS
// extractLaravel copies the embedded Laravel app to a temporary directory.
// FrankenPHP needs real filesystem paths — it cannot serve from embed.FS.
// Returns the path to the extracted Laravel root.
func extractLaravel() (string, error) {
tmpDir, err := os.MkdirTemp("", "core-app-laravel-*")
if err != nil {
return "", fmt.Errorf("create temp dir: %w", err)
}
err = fs.WalkDir(laravelFiles, "laravel", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel("laravel", path)
if err != nil {
return err
}
targetPath := filepath.Join(tmpDir, relPath)
if d.IsDir() {
return os.MkdirAll(targetPath, 0o755)
}
data, err := laravelFiles.ReadFile(path)
if err != nil {
return fmt.Errorf("read embedded %s: %w", path, err)
}
return os.WriteFile(targetPath, data, 0o644)
})
if err != nil {
os.RemoveAll(tmpDir)
return "", fmt.Errorf("extract Laravel: %w", err)
}
return tmpDir, nil
}

167
cmd/core-app/env.go Normal file
View file

@ -0,0 +1,167 @@
package main
import (
"crypto/rand"
"encoding/base64"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
)
// AppEnvironment holds the resolved paths for the running application.
type AppEnvironment struct {
// DataDir is the persistent data directory (survives app updates).
DataDir string
// LaravelRoot is the extracted Laravel app in the temp directory.
LaravelRoot string
// DatabasePath is the full path to the SQLite database file.
DatabasePath string
}
// PrepareEnvironment creates data directories, generates .env, and symlinks
// storage so Laravel can write to persistent locations.
func PrepareEnvironment(laravelRoot string) (*AppEnvironment, error) {
dataDir, err := resolveDataDir()
if err != nil {
return nil, fmt.Errorf("resolve data dir: %w", err)
}
env := &AppEnvironment{
DataDir: dataDir,
LaravelRoot: laravelRoot,
DatabasePath: filepath.Join(dataDir, "core-app.sqlite"),
}
// Create persistent directories
dirs := []string{
dataDir,
filepath.Join(dataDir, "storage", "app"),
filepath.Join(dataDir, "storage", "framework", "cache", "data"),
filepath.Join(dataDir, "storage", "framework", "sessions"),
filepath.Join(dataDir, "storage", "framework", "views"),
filepath.Join(dataDir, "storage", "logs"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create dir %s: %w", dir, err)
}
}
// Create empty SQLite database if it doesn't exist
if _, err := os.Stat(env.DatabasePath); os.IsNotExist(err) {
if err := os.WriteFile(env.DatabasePath, nil, 0o644); err != nil {
return nil, fmt.Errorf("create database: %w", err)
}
log.Printf("Created new database: %s", env.DatabasePath)
}
// Replace the extracted storage/ with a symlink to the persistent one
extractedStorage := filepath.Join(laravelRoot, "storage")
os.RemoveAll(extractedStorage)
persistentStorage := filepath.Join(dataDir, "storage")
if err := os.Symlink(persistentStorage, extractedStorage); err != nil {
return nil, fmt.Errorf("symlink storage: %w", err)
}
// Generate .env file with resolved paths
if err := writeEnvFile(laravelRoot, env); err != nil {
return nil, fmt.Errorf("write .env: %w", err)
}
return env, nil
}
// resolveDataDir returns the OS-appropriate persistent data directory.
func resolveDataDir() (string, error) {
var base string
switch runtime.GOOS {
case "darwin":
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
base = filepath.Join(home, "Library", "Application Support", "core-app")
case "linux":
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
base = filepath.Join(xdg, "core-app")
} else {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
base = filepath.Join(home, ".local", "share", "core-app")
}
default:
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
base = filepath.Join(home, ".core-app")
}
return base, nil
}
// writeEnvFile generates the Laravel .env with resolved runtime paths.
func writeEnvFile(laravelRoot string, env *AppEnvironment) error {
appKey, err := loadOrGenerateAppKey(env.DataDir)
if err != nil {
return fmt.Errorf("app key: %w", err)
}
content := fmt.Sprintf(`APP_NAME="Core App"
APP_ENV=production
APP_KEY=%s
APP_DEBUG=false
APP_URL=http://localhost
DB_CONNECTION=sqlite
DB_DATABASE="%s"
CACHE_STORE=file
SESSION_DRIVER=file
LOG_CHANNEL=single
LOG_LEVEL=warning
`, appKey, env.DatabasePath)
return os.WriteFile(filepath.Join(laravelRoot, ".env"), []byte(content), 0o644)
}
// loadOrGenerateAppKey loads an existing APP_KEY from the data dir,
// or generates a new one and persists it.
func loadOrGenerateAppKey(dataDir string) (string, error) {
keyFile := filepath.Join(dataDir, ".app-key")
data, err := os.ReadFile(keyFile)
if err == nil && len(data) > 0 {
return string(data), nil
}
// Generate a new 32-byte key
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return "", fmt.Errorf("generate key: %w", err)
}
appKey := "base64:" + base64.StdEncoding.EncodeToString(key)
if err := os.WriteFile(keyFile, []byte(appKey), 0o600); err != nil {
return "", fmt.Errorf("save key: %w", err)
}
log.Printf("Generated new APP_KEY (saved to %s)", keyFile)
return appKey, nil
}
// appendEnv appends a key=value pair to the Laravel .env file.
func appendEnv(laravelRoot, key, value string) error {
envFile := filepath.Join(laravelRoot, ".env")
f, err := os.OpenFile(envFile, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
_, err = fmt.Fprintf(f, "%s=\"%s\"\n", key, value)
return err
}

67
cmd/core-app/go.mod Normal file
View file

@ -0,0 +1,67 @@
module forge.lthn.ai/core/go/cmd/core-app
go 1.25.5
require (
github.com/dunglas/frankenphp v1.5.0
github.com/wailsapp/wails/v3 v3.0.0-alpha.64
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dolthub/maphash v0.1.0 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/gammazero/deque v1.0.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.7.0 // indirect
github.com/go-git/go-git/v5 v5.16.4 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/lmittmann/tint v1.1.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/maypok86/otter v1.2.4 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.21.1 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.63.0 // indirect
github.com/prometheus/procfs v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)
replace forge.lthn.ai/core/go => ../..

185
cmd/core-app/go.sum Normal file
View file

@ -0,0 +1,185 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
github.com/dunglas/frankenphp v1.5.0 h1:mrkJNe2gxlqYijGSpYIVbbRYxjYw2bmgAeDFqwREEk4=
github.com/dunglas/frankenphp v1.5.0/go.mod h1:tU9EirkVR0EuIr69IT1XBjSE6YfQY88tZlgkAvLPdOw=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gammazero/deque v1.0.0 h1:LTmimT8H7bXkkCy6gZX7zNLtkbz4NdS2z8LZuor3j34=
github.com/gammazero/deque v1.0.0/go.mod h1:iflpYvtGfM3U8S8j+sZEKIak3SAKYpA5/SQewgfXDKo=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/maypok86/otter v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc=
github.com/maypok86/otter v1.2.4/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/wails/v3 v3.0.0-alpha.64 h1:xAhLFVfdbg7XdZQ5mMQmBv2BglWu8hMqe50Z+3UJvBs=
github.com/wailsapp/wails/v3 v3.0.0-alpha.64/go.mod h1:zvgNL/mlFcX8aRGu6KOz9AHrMmTBD+4hJRQIONqF/Yw=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

137
cmd/core-app/handler.go Normal file
View file

@ -0,0 +1,137 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/dunglas/frankenphp"
)
// PHPHandler implements http.Handler by delegating to FrankenPHP.
// It resolves URLs to files (like Caddy's try_files) before passing
// requests to the PHP runtime.
type PHPHandler struct {
docRoot string
laravelRoot string
}
// NewPHPHandler extracts the embedded Laravel app, prepares the environment,
// initialises FrankenPHP with worker mode, and returns the handler.
func NewPHPHandler() (*PHPHandler, *AppEnvironment, func(), error) {
// Extract embedded Laravel to temp directory
laravelRoot, err := extractLaravel()
if err != nil {
return nil, nil, nil, fmt.Errorf("extract Laravel: %w", err)
}
// Prepare persistent environment
env, err := PrepareEnvironment(laravelRoot)
if err != nil {
os.RemoveAll(laravelRoot)
return nil, nil, nil, fmt.Errorf("prepare environment: %w", err)
}
docRoot := filepath.Join(laravelRoot, "public")
log.Printf("Laravel root: %s", laravelRoot)
log.Printf("Document root: %s", docRoot)
log.Printf("Data directory: %s", env.DataDir)
log.Printf("Database: %s", env.DatabasePath)
// Try Octane worker mode first, fall back to standard mode.
// Worker mode keeps Laravel booted in memory — sub-ms response times.
workerScript := filepath.Join(laravelRoot, "vendor", "laravel", "octane", "bin", "frankenphp-worker.php")
workerEnv := map[string]string{
"APP_BASE_PATH": laravelRoot,
"FRANKENPHP_WORKER": "1",
}
workerMode := false
if _, err := os.Stat(workerScript); err == nil {
if err := frankenphp.Init(
frankenphp.WithNumThreads(4),
frankenphp.WithWorkers("laravel", workerScript, 2, workerEnv, nil),
frankenphp.WithPhpIni(map[string]string{
"display_errors": "Off",
"opcache.enable": "1",
}),
); err != nil {
log.Printf("Worker mode init failed (%v), falling back to standard mode", err)
} else {
workerMode = true
}
}
if !workerMode {
if err := frankenphp.Init(
frankenphp.WithNumThreads(4),
frankenphp.WithPhpIni(map[string]string{
"display_errors": "Off",
"opcache.enable": "1",
}),
); err != nil {
os.RemoveAll(laravelRoot)
return nil, nil, nil, fmt.Errorf("init FrankenPHP: %w", err)
}
}
if workerMode {
log.Println("FrankenPHP initialised (Octane worker mode, 2 workers)")
} else {
log.Println("FrankenPHP initialised (standard mode, 4 threads)")
}
cleanup := func() {
frankenphp.Shutdown()
os.RemoveAll(laravelRoot)
}
handler := &PHPHandler{
docRoot: docRoot,
laravelRoot: laravelRoot,
}
return handler, env, cleanup, nil
}
func (h *PHPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
urlPath := r.URL.Path
filePath := filepath.Join(h.docRoot, filepath.Clean(urlPath))
info, err := os.Stat(filePath)
if err == nil && info.IsDir() {
// Directory → try index.php inside it
urlPath = strings.TrimRight(urlPath, "/") + "/index.php"
} else if err != nil && !strings.HasSuffix(urlPath, ".php") {
// File not found and not a .php request → front controller
urlPath = "/index.php"
}
// Serve static assets directly (CSS, JS, images)
if !strings.HasSuffix(urlPath, ".php") {
staticPath := filepath.Join(h.docRoot, filepath.Clean(urlPath))
if info, err := os.Stat(staticPath); err == nil && !info.IsDir() {
http.ServeFile(w, r, staticPath)
return
}
}
// Route to FrankenPHP
r.URL.Path = urlPath
req, err := frankenphp.NewRequestWithContext(r,
frankenphp.WithRequestDocumentRoot(h.docRoot, false),
)
if err != nil {
http.Error(w, fmt.Sprintf("FrankenPHP request error: %v", err), http.StatusInternalServerError)
return
}
if err := frankenphp.ServeHTTP(w, req); err != nil {
http.Error(w, fmt.Sprintf("FrankenPHP serve error: %v", err), http.StatusInternalServerError)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 B

View file

@ -0,0 +1,24 @@
// Package icons provides embedded icon assets for the Core App.
package icons
import _ "embed"
// TrayTemplate is the template icon for macOS systray (22x22 PNG, black on transparent).
//
//go:embed tray-template.png
var TrayTemplate []byte
// TrayLight is the light mode icon for Windows/Linux systray.
//
//go:embed tray-light.png
var TrayLight []byte
// TrayDark is the dark mode icon for Windows/Linux systray.
//
//go:embed tray-dark.png
var TrayDark []byte
// AppIcon is the main application icon.
//
//go:embed appicon.png
var AppIcon []byte

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 B

View file

@ -0,0 +1,13 @@
APP_NAME="Core App"
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
DB_CONNECTION=sqlite
DB_DATABASE=/tmp/core-app/database.sqlite
CACHE_STORE=file
SESSION_DRIVER=file
LOG_CHANNEL=single
LOG_LEVEL=warning

5
cmd/core-app/laravel/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
/vendor/
/node_modules/
/.env
/bootstrap/cache/*.php
/storage/*.key

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\AllowanceService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class QuotaMiddleware
{
public function __construct(
private readonly AllowanceService $allowanceService,
) {}
public function handle(Request $request, Closure $next): Response
{
$agentId = $request->header('X-Agent-ID', $request->input('agent_id', ''));
$model = $request->input('model', '');
if ($agentId === '') {
return response()->json([
'error' => 'agent_id is required',
], 400);
}
$result = $this->allowanceService->check($agentId, $model);
if (! $result['allowed']) {
return response()->json([
'error' => 'quota_exceeded',
'status' => $result['status'],
'reason' => $result['reason'],
'remaining_tokens' => $result['remaining_tokens'],
'remaining_jobs' => $result['remaining_jobs'],
], 429);
}
// Attach quota info to request for downstream use
$request->merge(['_quota' => $result]);
return $next($request);
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Livewire;
use Livewire\Component;
class Counter extends Component
{
public int $count = 0;
public function increment(): void
{
$this->count++;
}
public function decrement(): void
{
$this->count--;
}
public function render()
{
return view('livewire.counter');
}
}

View file

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Dashboard;
use Livewire\Component;
class ActivityFeed extends Component
{
public array $entries = [];
public string $agentFilter = 'all';
public string $typeFilter = 'all';
public bool $showOnlyQuestions = false;
public function mount(): void
{
$this->loadEntries();
}
public function loadEntries(): void
{
// Placeholder data — will be replaced with real-time WebSocket feed
$this->entries = [
[
'id' => 'act-001',
'agent' => 'Athena',
'type' => 'code_write',
'message' => 'Created AgentFleet Livewire component',
'job' => '#96',
'timestamp' => now()->subMinutes(2)->toIso8601String(),
'is_question' => false,
],
[
'id' => 'act-002',
'agent' => 'Athena',
'type' => 'tool_call',
'message' => 'Read file: cmd/core-app/laravel/composer.json',
'job' => '#96',
'timestamp' => now()->subMinutes(5)->toIso8601String(),
'is_question' => false,
],
[
'id' => 'act-003',
'agent' => 'Clotho',
'type' => 'question',
'message' => 'Should I apply the fix to both the TCP and Unix socket transports, or just TCP?',
'job' => '#84',
'timestamp' => now()->subMinutes(8)->toIso8601String(),
'is_question' => true,
],
[
'id' => 'act-004',
'agent' => 'Virgil',
'type' => 'pr_created',
'message' => 'Opened PR #89: fix WebSocket reconnection logic',
'job' => '#89',
'timestamp' => now()->subMinutes(15)->toIso8601String(),
'is_question' => false,
],
[
'id' => 'act-005',
'agent' => 'Virgil',
'type' => 'test_run',
'message' => 'All 47 tests passed (0.8s)',
'job' => '#89',
'timestamp' => now()->subMinutes(18)->toIso8601String(),
'is_question' => false,
],
[
'id' => 'act-006',
'agent' => 'Athena',
'type' => 'git_push',
'message' => 'Pushed branch feat/agentic-dashboard',
'job' => '#96',
'timestamp' => now()->subMinutes(22)->toIso8601String(),
'is_question' => false,
],
[
'id' => 'act-007',
'agent' => 'Clotho',
'type' => 'code_write',
'message' => 'Added input validation for MCP file_write paths',
'job' => '#84',
'timestamp' => now()->subMinutes(30)->toIso8601String(),
'is_question' => false,
],
];
}
public function getFilteredEntriesProperty(): array
{
return array_filter($this->entries, function ($entry) {
if ($this->showOnlyQuestions && !$entry['is_question']) {
return false;
}
if ($this->agentFilter !== 'all' && $entry['agent'] !== $this->agentFilter) {
return false;
}
if ($this->typeFilter !== 'all' && $entry['type'] !== $this->typeFilter) {
return false;
}
return true;
});
}
public function render()
{
return view('livewire.dashboard.activity-feed');
}
}

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Dashboard;
use Livewire\Component;
class AgentFleet extends Component
{
/** @var array<int, array{name: string, host: string, model: string, status: string, job: string, heartbeat: string, uptime: string}> */
public array $agents = [];
public ?string $selectedAgent = null;
public function mount(): void
{
$this->loadAgents();
}
public function loadAgents(): void
{
// Placeholder data — will be replaced with real API calls to Go backend
$this->agents = [
[
'id' => 'athena',
'name' => 'Athena',
'host' => 'studio.snider.dev',
'model' => 'claude-opus-4-6',
'status' => 'working',
'job' => '#96 agentic dashboard',
'heartbeat' => 'green',
'uptime' => '4h 23m',
'tokens_today' => 142_580,
'jobs_completed' => 3,
],
[
'id' => 'virgil',
'name' => 'Virgil',
'host' => 'studio.snider.dev',
'model' => 'claude-opus-4-6',
'status' => 'idle',
'job' => '',
'heartbeat' => 'green',
'uptime' => '12h 07m',
'tokens_today' => 89_230,
'jobs_completed' => 5,
],
[
'id' => 'clotho',
'name' => 'Clotho',
'host' => 'darwin-au',
'model' => 'claude-sonnet-4-5',
'status' => 'working',
'job' => '#84 security audit',
'heartbeat' => 'yellow',
'uptime' => '1h 45m',
'tokens_today' => 34_100,
'jobs_completed' => 1,
],
[
'id' => 'charon',
'name' => 'Charon',
'host' => 'linux.snider.dev',
'model' => 'claude-haiku-4-5',
'status' => 'unhealthy',
'job' => '',
'heartbeat' => 'red',
'uptime' => '0m',
'tokens_today' => 0,
'jobs_completed' => 0,
],
];
}
public function selectAgent(string $agentId): void
{
$this->selectedAgent = $this->selectedAgent === $agentId ? null : $agentId;
}
public function render()
{
return view('livewire.dashboard.agent-fleet');
}
}

View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Dashboard;
use Livewire\Component;
class HumanActions extends Component
{
public array $pendingQuestions = [];
public array $reviewGates = [];
public string $answerText = '';
public ?string $answeringId = null;
public function mount(): void
{
$this->loadPending();
}
public function loadPending(): void
{
// Placeholder data — will be replaced with real data from Go backend
$this->pendingQuestions = [
[
'id' => 'q-001',
'agent' => 'Clotho',
'job' => '#84',
'question' => 'Should I apply the fix to both the TCP and Unix socket transports, or just TCP?',
'asked_at' => now()->subMinutes(8)->toIso8601String(),
'context' => 'Working on security audit — found unvalidated input in transport layer.',
],
];
$this->reviewGates = [
[
'id' => 'rg-001',
'agent' => 'Virgil',
'job' => '#89',
'type' => 'pr_review',
'title' => 'PR #89: fix WebSocket reconnection logic',
'description' => 'Adds exponential backoff and connection state tracking.',
'submitted_at' => now()->subMinutes(15)->toIso8601String(),
],
];
}
public function startAnswer(string $questionId): void
{
$this->answeringId = $questionId;
$this->answerText = '';
}
public function submitAnswer(): void
{
if (! $this->answeringId || trim($this->answerText) === '') {
return;
}
// Remove answered question from list
$this->pendingQuestions = array_values(
array_filter($this->pendingQuestions, fn ($q) => $q['id'] !== $this->answeringId)
);
$this->answeringId = null;
$this->answerText = '';
}
public function cancelAnswer(): void
{
$this->answeringId = null;
$this->answerText = '';
}
public function approveGate(string $gateId): void
{
$this->reviewGates = array_values(
array_filter($this->reviewGates, fn ($g) => $g['id'] !== $gateId)
);
}
public function rejectGate(string $gateId): void
{
$this->reviewGates = array_values(
array_filter($this->reviewGates, fn ($g) => $g['id'] !== $gateId)
);
}
public function render()
{
return view('livewire.dashboard.human-actions');
}
}

View file

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Dashboard;
use Livewire\Component;
class JobQueue extends Component
{
public array $jobs = [];
public string $statusFilter = 'all';
public string $agentFilter = 'all';
public function mount(): void
{
$this->loadJobs();
}
public function loadJobs(): void
{
// Placeholder data — will be replaced with real API calls to Go backend
$this->jobs = [
[
'id' => 'job-001',
'issue' => '#96',
'repo' => 'host-uk/core',
'title' => 'feat(agentic): real-time dashboard',
'agent' => 'Athena',
'status' => 'in_progress',
'priority' => 1,
'queued_at' => now()->subMinutes(45)->toIso8601String(),
'started_at' => now()->subMinutes(30)->toIso8601String(),
],
[
'id' => 'job-002',
'issue' => '#84',
'repo' => 'host-uk/core',
'title' => 'fix: security audit findings',
'agent' => 'Clotho',
'status' => 'in_progress',
'priority' => 2,
'queued_at' => now()->subHours(2)->toIso8601String(),
'started_at' => now()->subHours(1)->toIso8601String(),
],
[
'id' => 'job-003',
'issue' => '#102',
'repo' => 'host-uk/core',
'title' => 'feat: add rate limiting to MCP',
'agent' => null,
'status' => 'queued',
'priority' => 3,
'queued_at' => now()->subMinutes(10)->toIso8601String(),
'started_at' => null,
],
[
'id' => 'job-004',
'issue' => '#89',
'repo' => 'host-uk/core',
'title' => 'fix: WebSocket reconnection',
'agent' => 'Virgil',
'status' => 'review',
'priority' => 2,
'queued_at' => now()->subHours(4)->toIso8601String(),
'started_at' => now()->subHours(3)->toIso8601String(),
],
[
'id' => 'job-005',
'issue' => '#78',
'repo' => 'host-uk/core',
'title' => 'docs: update CLAUDE.md',
'agent' => 'Virgil',
'status' => 'completed',
'priority' => 4,
'queued_at' => now()->subHours(6)->toIso8601String(),
'started_at' => now()->subHours(5)->toIso8601String(),
],
];
}
public function updatedStatusFilter(): void
{
// Livewire auto-updates the view
}
public function cancelJob(string $jobId): void
{
$this->jobs = array_map(function ($job) use ($jobId) {
if ($job['id'] === $jobId && in_array($job['status'], ['queued', 'in_progress'])) {
$job['status'] = 'cancelled';
}
return $job;
}, $this->jobs);
}
public function retryJob(string $jobId): void
{
$this->jobs = array_map(function ($job) use ($jobId) {
if ($job['id'] === $jobId && in_array($job['status'], ['failed', 'cancelled'])) {
$job['status'] = 'queued';
$job['agent'] = null;
}
return $job;
}, $this->jobs);
}
public function getFilteredJobsProperty(): array
{
return array_filter($this->jobs, function ($job) {
if ($this->statusFilter !== 'all' && $job['status'] !== $this->statusFilter) {
return false;
}
if ($this->agentFilter !== 'all' && ($job['agent'] ?? '') !== $this->agentFilter) {
return false;
}
return true;
});
}
public function render()
{
return view('livewire.dashboard.job-queue');
}
}

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Dashboard;
use Livewire\Component;
class Metrics extends Component
{
public array $stats = [];
public array $throughputData = [];
public array $costBreakdown = [];
public float $budgetUsed = 0;
public float $budgetLimit = 0;
public function mount(): void
{
$this->loadMetrics();
}
public function loadMetrics(): void
{
// Placeholder data — will be replaced with real metrics from Go backend
$this->stats = [
'jobs_completed' => 12,
'prs_merged' => 8,
'tokens_used' => 1_245_800,
'cost_today' => 18.42,
'active_agents' => 3,
'queue_depth' => 4,
];
$this->budgetUsed = 18.42;
$this->budgetLimit = 50.00;
// Hourly throughput for chart
$this->throughputData = [
['hour' => '00:00', 'jobs' => 0, 'tokens' => 0],
['hour' => '02:00', 'jobs' => 0, 'tokens' => 0],
['hour' => '04:00', 'jobs' => 1, 'tokens' => 45_000],
['hour' => '06:00', 'jobs' => 2, 'tokens' => 120_000],
['hour' => '08:00', 'jobs' => 3, 'tokens' => 195_000],
['hour' => '10:00', 'jobs' => 2, 'tokens' => 280_000],
['hour' => '12:00', 'jobs' => 1, 'tokens' => 340_000],
['hour' => '14:00', 'jobs' => 3, 'tokens' => 450_000],
];
$this->costBreakdown = [
['model' => 'claude-opus-4-6', 'cost' => 12.80, 'tokens' => 856_000],
['model' => 'claude-sonnet-4-5', 'cost' => 4.20, 'tokens' => 312_000],
['model' => 'claude-haiku-4-5', 'cost' => 1.42, 'tokens' => 77_800],
];
}
public function render()
{
return view('livewire.dashboard.metrics');
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AgentAllowance extends Model
{
protected $fillable = [
'agent_id',
'daily_token_limit',
'daily_job_limit',
'concurrent_jobs',
'max_job_duration_minutes',
'model_allowlist',
];
protected function casts(): array
{
return [
'daily_token_limit' => 'integer',
'daily_job_limit' => 'integer',
'concurrent_jobs' => 'integer',
'max_job_duration_minutes' => 'integer',
'model_allowlist' => 'array',
];
}
public function usageRecords(): HasMany
{
return $this->hasMany(QuotaUsage::class, 'agent_id', 'agent_id');
}
public function todayUsage(): ?QuotaUsage
{
return $this->usageRecords()
->where('period_date', now()->toDateString())
->first();
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ModelQuota extends Model
{
protected $fillable = [
'model',
'daily_token_budget',
'hourly_rate_limit',
'cost_ceiling',
];
protected function casts(): array
{
return [
'daily_token_budget' => 'integer',
'hourly_rate_limit' => 'integer',
'cost_ceiling' => 'integer',
];
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class QuotaUsage extends Model
{
protected $table = 'quota_usage';
protected $fillable = [
'agent_id',
'tokens_used',
'jobs_started',
'active_jobs',
'period_date',
];
protected function casts(): array
{
return [
'tokens_used' => 'integer',
'jobs_started' => 'integer',
'active_jobs' => 'integer',
'period_date' => 'date',
];
}
public function allowance(): BelongsTo
{
return $this->belongsTo(AgentAllowance::class, 'agent_id', 'agent_id');
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class UsageReport extends Model
{
protected $fillable = [
'agent_id',
'job_id',
'model',
'tokens_in',
'tokens_out',
'event',
'reported_at',
];
protected function casts(): array
{
return [
'tokens_in' => 'integer',
'tokens_out' => 'integer',
'reported_at' => 'datetime',
];
}
}

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Services\Forgejo\ForgejoService;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\ServiceProvider;
use Throwable;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(ForgejoService::class, function ($app): ForgejoService {
/** @var array<string, mixed> $config */
$config = $app['config']->get('forgejo', []);
return new ForgejoService(
instances: $config['instances'] ?? [],
defaultInstance: $config['default'] ?? 'forge',
timeout: $config['timeout'] ?? 30,
retryTimes: $config['retry_times'] ?? 3,
retrySleep: $config['retry_sleep'] ?? 500,
);
});
}
public function boot(): void
{
// Auto-migrate on first boot. Single-user desktop app with
// SQLite — safe to run on every startup. The --force flag
// is required in production, --no-interaction prevents prompts.
try {
Artisan::call('migrate', [
'--force' => true,
'--no-interaction' => true,
]);
} catch (Throwable) {
// Silently skip — DB might not exist yet (e.g. during
// composer operations or first extraction).
}
}
}

View file

@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\AgentAllowance;
use App\Models\ModelQuota;
use App\Models\QuotaUsage;
use App\Models\UsageReport;
class AllowanceService
{
/**
* Pre-dispatch check: verify agent has remaining allowance.
*
* @return array{allowed: bool, status: string, remaining_tokens: int, remaining_jobs: int, reason: ?string}
*/
public function check(string $agentId, string $model = ''): array
{
$allowance = AgentAllowance::where('agent_id', $agentId)->first();
if (! $allowance) {
return [
'allowed' => false,
'status' => 'exceeded',
'remaining_tokens' => 0,
'remaining_jobs' => 0,
'reason' => 'no allowance configured for agent',
];
}
$usage = QuotaUsage::firstOrCreate(
['agent_id' => $agentId, 'period_date' => now()->toDateString()],
['tokens_used' => 0, 'jobs_started' => 0, 'active_jobs' => 0],
);
$result = [
'allowed' => true,
'status' => 'ok',
'remaining_tokens' => -1,
'remaining_jobs' => -1,
'reason' => null,
];
// Check model allowlist
if ($model !== '' && ! empty($allowance->model_allowlist)) {
if (! in_array($model, $allowance->model_allowlist, true)) {
return array_merge($result, [
'allowed' => false,
'status' => 'exceeded',
'reason' => "model not in allowlist: {$model}",
]);
}
}
// Check daily token limit
if ($allowance->daily_token_limit > 0) {
$remaining = $allowance->daily_token_limit - $usage->tokens_used;
$result['remaining_tokens'] = $remaining;
if ($remaining <= 0) {
return array_merge($result, [
'allowed' => false,
'status' => 'exceeded',
'reason' => 'daily token limit exceeded',
]);
}
$ratio = $usage->tokens_used / $allowance->daily_token_limit;
if ($ratio >= 0.8) {
$result['status'] = 'warning';
}
}
// Check daily job limit
if ($allowance->daily_job_limit > 0) {
$remaining = $allowance->daily_job_limit - $usage->jobs_started;
$result['remaining_jobs'] = $remaining;
if ($remaining <= 0) {
return array_merge($result, [
'allowed' => false,
'status' => 'exceeded',
'reason' => 'daily job limit exceeded',
]);
}
}
// Check concurrent jobs
if ($allowance->concurrent_jobs > 0 && $usage->active_jobs >= $allowance->concurrent_jobs) {
return array_merge($result, [
'allowed' => false,
'status' => 'exceeded',
'reason' => 'concurrent job limit reached',
]);
}
// Check global model quota
if ($model !== '') {
$modelQuota = ModelQuota::where('model', $model)->first();
if ($modelQuota && $modelQuota->daily_token_budget > 0) {
$modelUsage = UsageReport::where('model', $model)
->whereDate('reported_at', now()->toDateString())
->sum(\DB::raw('tokens_in + tokens_out'));
if ($modelUsage >= $modelQuota->daily_token_budget) {
return array_merge($result, [
'allowed' => false,
'status' => 'exceeded',
'reason' => "global model token budget exceeded for: {$model}",
]);
}
}
}
return $result;
}
/**
* Record usage from an agent runner report.
*/
public function recordUsage(array $report): void
{
$agentId = $report['agent_id'];
$totalTokens = ($report['tokens_in'] ?? 0) + ($report['tokens_out'] ?? 0);
$usage = QuotaUsage::firstOrCreate(
['agent_id' => $agentId, 'period_date' => now()->toDateString()],
['tokens_used' => 0, 'jobs_started' => 0, 'active_jobs' => 0],
);
// Persist the raw report
UsageReport::create([
'agent_id' => $report['agent_id'],
'job_id' => $report['job_id'],
'model' => $report['model'] ?? null,
'tokens_in' => $report['tokens_in'] ?? 0,
'tokens_out' => $report['tokens_out'] ?? 0,
'event' => $report['event'],
'reported_at' => $report['timestamp'] ?? now(),
]);
match ($report['event']) {
'job_started' => $usage->increment('jobs_started') || $usage->increment('active_jobs'),
'job_completed' => $this->handleCompleted($usage, $totalTokens),
'job_failed' => $this->handleFailed($usage, $totalTokens),
'job_cancelled' => $this->handleCancelled($usage, $totalTokens),
default => null,
};
}
/**
* Reset daily usage counters for an agent.
*/
public function resetAgent(string $agentId): void
{
QuotaUsage::updateOrCreate(
['agent_id' => $agentId, 'period_date' => now()->toDateString()],
['tokens_used' => 0, 'jobs_started' => 0, 'active_jobs' => 0],
);
}
private function handleCompleted(QuotaUsage $usage, int $totalTokens): void
{
$usage->increment('tokens_used', $totalTokens);
$usage->decrement('active_jobs');
}
private function handleFailed(QuotaUsage $usage, int $totalTokens): void
{
$returnAmount = intdiv($totalTokens, 2);
$usage->increment('tokens_used', $totalTokens - $returnAmount);
$usage->decrement('active_jobs');
}
private function handleCancelled(QuotaUsage $usage, int $totalTokens): void
{
$usage->decrement('active_jobs');
// 100% returned — no token charge
}
}

View file

@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Services\Forgejo;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Low-level HTTP client for a single Forgejo instance.
*
* Wraps the Laravel HTTP client with token auth, retry, and
* base-URL scoping so callers never deal with raw HTTP details.
*/
class ForgejoClient
{
private PendingRequest $http;
public function __construct(
private readonly string $baseUrl,
private readonly string $token,
int $timeout = 30,
int $retryTimes = 3,
int $retrySleep = 500,
) {
if ($this->token === '') {
throw new RuntimeException("Forgejo API token is required for {$this->baseUrl}");
}
$this->http = Http::baseUrl(rtrim($this->baseUrl, '/') . '/api/v1')
->withHeaders([
'Authorization' => "token {$this->token}",
'Accept' => 'application/json',
'Content-Type' => 'application/json',
])
->timeout($timeout)
->retry($retryTimes, $retrySleep, fn (?\Throwable $e, PendingRequest $req): bool =>
$e instanceof \Illuminate\Http\Client\ConnectionException
);
}
public function baseUrl(): string
{
return $this->baseUrl;
}
// ----- Generic verbs -----
/** @return array<string, mixed> */
public function get(string $path, array $query = []): array
{
return $this->decodeOrFail($this->http->get($path, $query));
}
/** @return array<string, mixed> */
public function post(string $path, array $data = []): array
{
return $this->decodeOrFail($this->http->post($path, $data));
}
/** @return array<string, mixed> */
public function patch(string $path, array $data = []): array
{
return $this->decodeOrFail($this->http->patch($path, $data));
}
/** @return array<string, mixed> */
public function put(string $path, array $data = []): array
{
return $this->decodeOrFail($this->http->put($path, $data));
}
public function delete(string $path): void
{
$response = $this->http->delete($path);
if ($response->failed()) {
throw new RuntimeException(
"Forgejo DELETE {$path} failed [{$response->status()}]: {$response->body()}"
);
}
}
/**
* GET a path and return the raw response body as a string.
* Useful for endpoints that return non-JSON content (e.g. diffs).
*/
public function getRaw(string $path, array $query = []): string
{
$response = $this->http->get($path, $query);
if ($response->failed()) {
throw new RuntimeException(
"Forgejo GET {$path} failed [{$response->status()}]: {$response->body()}"
);
}
return $response->body();
}
/**
* Paginate through all pages of a list endpoint.
*
* @return list<array<string, mixed>>
*/
public function paginate(string $path, array $query = [], int $limit = 50): array
{
$all = [];
$page = 1;
do {
$response = $this->http->get($path, array_merge($query, [
'page' => $page,
'limit' => $limit,
]));
if ($response->failed()) {
throw new RuntimeException(
"Forgejo GET {$path} page {$page} failed [{$response->status()}]: {$response->body()}"
);
}
$items = $response->json();
if (!is_array($items) || $items === []) {
break;
}
array_push($all, ...$items);
// Forgejo returns total count in x-total-count header.
$total = (int) $response->header('x-total-count');
$page++;
} while (count($all) < $total);
return $all;
}
// ----- Internals -----
/** @return array<string, mixed> */
private function decodeOrFail(Response $response): array
{
if ($response->failed()) {
throw new RuntimeException(
"Forgejo API error [{$response->status()}]: {$response->body()}"
);
}
return $response->json() ?? [];
}
}

View file

@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
namespace App\Services\Forgejo;
use RuntimeException;
/**
* Business-logic layer for Forgejo operations.
*
* Manages multiple Forgejo instances (forge, dev, qa) and provides
* a unified API for issues, pull requests, repositories, and user
* management. Mirrors the Go pkg/forge API surface.
*/
class ForgejoService
{
/** @var array<string, ForgejoClient> */
private array $clients = [];
private string $defaultInstance;
/**
* @param array<string, array{url: string, token: string}> $instances
*/
public function __construct(
array $instances,
string $defaultInstance = 'forge',
private readonly int $timeout = 30,
private readonly int $retryTimes = 3,
private readonly int $retrySleep = 500,
) {
$this->defaultInstance = $defaultInstance;
foreach ($instances as $name => $cfg) {
if (($cfg['token'] ?? '') === '') {
continue; // skip unconfigured instances
}
$this->clients[$name] = new ForgejoClient(
baseUrl: $cfg['url'],
token: $cfg['token'],
timeout: $this->timeout,
retryTimes: $this->retryTimes,
retrySleep: $this->retrySleep,
);
}
}
// ----------------------------------------------------------------
// Instance resolution
// ----------------------------------------------------------------
public function client(?string $instance = null): ForgejoClient
{
$name = $instance ?? $this->defaultInstance;
return $this->clients[$name]
?? throw new RuntimeException("Forgejo instance '{$name}' is not configured or has no token");
}
/** @return list<string> */
public function instances(): array
{
return array_keys($this->clients);
}
// ----------------------------------------------------------------
// Issue Operations
// ----------------------------------------------------------------
/** @return array<string, mixed> */
public function createIssue(
string $owner,
string $repo,
string $title,
string $body = '',
array $labels = [],
string $assignee = '',
?string $instance = null,
): array {
$data = ['title' => $title, 'body' => $body];
if ($labels !== []) {
$data['labels'] = $labels;
}
if ($assignee !== '') {
$data['assignees'] = [$assignee];
}
return $this->client($instance)->post("/repos/{$owner}/{$repo}/issues", $data);
}
/** @return array<string, mixed> */
public function updateIssue(
string $owner,
string $repo,
int $number,
array $fields,
?string $instance = null,
): array {
return $this->client($instance)->patch("/repos/{$owner}/{$repo}/issues/{$number}", $fields);
}
public function closeIssue(string $owner, string $repo, int $number, ?string $instance = null): array
{
return $this->updateIssue($owner, $repo, $number, ['state' => 'closed'], $instance);
}
/** @return array<string, mixed> */
public function addComment(
string $owner,
string $repo,
int $number,
string $body,
?string $instance = null,
): array {
return $this->client($instance)->post(
"/repos/{$owner}/{$repo}/issues/{$number}/comments",
['body' => $body],
);
}
/**
* @return list<array<string, mixed>>
*/
public function listIssues(
string $owner,
string $repo,
string $state = 'open',
int $page = 1,
int $limit = 50,
?string $instance = null,
): array {
return $this->client($instance)->get("/repos/{$owner}/{$repo}/issues", [
'state' => $state,
'type' => 'issues',
'page' => $page,
'limit' => $limit,
]);
}
// ----------------------------------------------------------------
// Pull Request Operations
// ----------------------------------------------------------------
/** @return array<string, mixed> */
public function createPR(
string $owner,
string $repo,
string $head,
string $base,
string $title,
string $body = '',
?string $instance = null,
): array {
return $this->client($instance)->post("/repos/{$owner}/{$repo}/pulls", [
'head' => $head,
'base' => $base,
'title' => $title,
'body' => $body,
]);
}
public function mergePR(
string $owner,
string $repo,
int $number,
string $strategy = 'merge',
?string $instance = null,
): void {
$this->client($instance)->post("/repos/{$owner}/{$repo}/pulls/{$number}/merge", [
'Do' => $strategy,
'delete_branch_after_merge' => true,
]);
}
/**
* @return list<array<string, mixed>>
*/
public function listPRs(
string $owner,
string $repo,
string $state = 'open',
?string $instance = null,
): array {
return $this->client($instance)->paginate("/repos/{$owner}/{$repo}/pulls", [
'state' => $state,
]);
}
public function getPRDiff(string $owner, string $repo, int $number, ?string $instance = null): string
{
return $this->client($instance)->getRaw("/repos/{$owner}/{$repo}/pulls/{$number}.diff");
}
// ----------------------------------------------------------------
// Repository Operations
// ----------------------------------------------------------------
/**
* @return list<array<string, mixed>>
*/
public function listRepos(string $org, ?string $instance = null): array
{
return $this->client($instance)->paginate("/orgs/{$org}/repos");
}
/** @return array<string, mixed> */
public function getRepo(string $owner, string $name, ?string $instance = null): array
{
return $this->client($instance)->get("/repos/{$owner}/{$name}");
}
/** @return array<string, mixed> */
public function createBranch(
string $owner,
string $repo,
string $name,
string $from = '',
?string $instance = null,
): array {
$data = ['new_branch_name' => $name];
if ($from !== '') {
$data['old_branch_name'] = $from;
}
return $this->client($instance)->post("/repos/{$owner}/{$repo}/branches", $data);
}
public function deleteBranch(
string $owner,
string $repo,
string $name,
?string $instance = null,
): void {
$this->client($instance)->delete("/repos/{$owner}/{$repo}/branches/{$name}");
}
// ----------------------------------------------------------------
// User / Token Management
// ----------------------------------------------------------------
/** @return array<string, mixed> */
public function createUser(
string $username,
string $email,
string $password,
?string $instance = null,
): array {
return $this->client($instance)->post('/admin/users', [
'username' => $username,
'email' => $email,
'password' => $password,
'must_change_password' => false,
]);
}
/** @return array<string, mixed> */
public function createToken(
string $username,
string $name,
array $scopes = [],
?string $instance = null,
): array {
$data = ['name' => $name];
if ($scopes !== []) {
$data['scopes'] = $scopes;
}
return $this->client($instance)->post("/users/{$username}/tokens", $data);
}
public function revokeToken(string $username, int $tokenId, ?string $instance = null): void
{
$this->client($instance)->delete("/users/{$username}/tokens/{$tokenId}");
}
/** @return array<string, mixed> */
public function addToOrg(
string $username,
string $org,
int $teamId,
?string $instance = null,
): array {
return $this->client($instance)->put("/teams/{$teamId}/members/{$username}");
}
// ----------------------------------------------------------------
// Org Operations
// ----------------------------------------------------------------
/**
* @return list<array<string, mixed>>
*/
public function listOrgs(?string $instance = null): array
{
return $this->client($instance)->paginate('/user/orgs');
}
}

View file

@ -0,0 +1,21 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
define('LARAVEL_START', microtime(true));
require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
$status = $kernel->handle(
$input = new Symfony\Component\Console\Input\ArgvInput,
new Symfony\Component\Console\Output\ConsoleOutput
);
$kernel->terminate($input, $status);
exit($status);

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
)
->withMiddleware(function (Middleware $middleware) {
//
})
->withExceptions(function (Exceptions $exceptions) {
//
})
->create();

View file

@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
return [
App\Providers\AppServiceProvider::class,
];

View file

@ -0,0 +1,29 @@
{
"name": "host-uk/core-app",
"description": "Embedded Laravel application for Core App desktop",
"license": "EUPL-1.2",
"type": "project",
"require": {
"php": "^8.4",
"laravel/framework": "^12.0",
"laravel/octane": "^2.0",
"livewire/livewire": "^4.0"
},
"autoload": {
"psr-4": {
"App\\": "app/"
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true
},
"minimum-stability": "stable",
"prefer-stable": true,
"scripts": {
"post-autoload-dump": [
"@php artisan package:discover --ansi"
]
}
}

6149
cmd/core-app/laravel/composer.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
return [
'name' => env('APP_NAME', 'Core App'),
'env' => env('APP_ENV', 'production'),
'debug' => (bool) env('APP_DEBUG', false),
'url' => env('APP_URL', 'http://localhost'),
'timezone' => 'UTC',
'locale' => 'en',
'fallback_locale' => 'en',
'faker_locale' => 'en_GB',
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'maintenance' => [
'driver' => 'file',
],
];

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
return [
'default' => env('CACHE_STORE', 'file'),
'stores' => [
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'array' => [
'driver' => 'array',
'serialize' => false,
],
],
'prefix' => env('CACHE_PREFIX', 'core_app_cache_'),
];

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
return [
'default' => 'sqlite',
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => true,
'busy_timeout' => 5000,
'journal_mode' => 'wal',
'synchronous' => 'normal',
],
],
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
];

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Default Forgejo Instance
|--------------------------------------------------------------------------
|
| The instance name to use when no explicit instance is specified.
|
*/
'default' => env('FORGEJO_DEFAULT', 'forge'),
/*
|--------------------------------------------------------------------------
| Forgejo Instances
|--------------------------------------------------------------------------
|
| Each entry defines a Forgejo instance the platform can talk to.
| The service auto-routes by matching the configured URL.
|
| url Base URL of the Forgejo instance (no trailing slash)
| token Admin API token for the instance
|
*/
'instances' => [
'forge' => [
'url' => env('FORGEJO_FORGE_URL', 'https://forge.lthn.ai'),
'token' => env('FORGEJO_FORGE_TOKEN', ''),
],
'dev' => [
'url' => env('FORGEJO_DEV_URL', 'https://dev.lthn.ai'),
'token' => env('FORGEJO_DEV_TOKEN', ''),
],
'qa' => [
'url' => env('FORGEJO_QA_URL', 'https://qa.lthn.ai'),
'token' => env('FORGEJO_QA_TOKEN', ''),
],
],
/*
|--------------------------------------------------------------------------
| HTTP Client Settings
|--------------------------------------------------------------------------
*/
'timeout' => (int) env('FORGEJO_TIMEOUT', 30),
'retry_times' => (int) env('FORGEJO_RETRY_TIMES', 3),
'retry_sleep' => (int) env('FORGEJO_RETRY_SLEEP', 500),
];

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
return [
'default' => env('LOG_CHANNEL', 'single'),
'channels' => [
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'warning'),
'replace_placeholders' => true,
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => Monolog\Handler\StreamHandler::class,
'with' => [
'stream' => 'php://stderr',
],
'processors' => [Monolog\Processor\PsrLogMessageProcessor::class],
],
],
];

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
return [
'driver' => env('SESSION_DRIVER', 'file'),
'lifetime' => env('SESSION_LIFETIME', 120),
'expire_on_close' => true,
'encrypt' => false,
'files' => storage_path('framework/sessions'),
'connection' => env('SESSION_CONNECTION'),
'table' => 'sessions',
'store' => env('SESSION_STORE'),
'lottery' => [2, 100],
'cookie' => env('SESSION_COOKIE', 'core_app_session'),
'path' => '/',
'domain' => null,
'secure' => false,
'http_only' => true,
'same_site' => 'lax',
'partitioned' => false,
];

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
return [
'paths' => [
resource_path('views'),
],
'compiled' => env('VIEW_COMPILED_PATH', realpath(storage_path('framework/views'))),
];

Binary file not shown.

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
public function down(): void
{
Schema::dropIfExists('sessions');
}
};

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
}
public function down(): void
{
Schema::dropIfExists('cache_locks');
Schema::dropIfExists('cache');
}
};

Some files were not shown because too many files have changed in this diff Show more