Compare commits

..

35 commits
dev ... main

Author SHA1 Message Date
Snider
ced9a7037f chore: bump forge.lthn.ai dep versions to latest tags
Some checks failed
Deploy / build (push) Failing after 6s
Security Scan / security (push) Successful in 30s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-23 06:49:51 +00:00
Snider
bddea677c7 chore: add Go repo norms (badges, contributing, lint, taskfile, editorconfig)
Some checks are pending
Deploy / build (push) Waiting to run
Security Scan / security (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-23 06:45:56 +00:00
Snider
2ded0002fc chore: refresh go.sum after upstream tag updates
Some checks failed
Security Scan / security (push) Waiting to run
Deploy / build (push) Has been cancelled
Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-23 06:35:19 +00:00
Snider
fa3a7bcd83 feat(cli): add Go 1.26 iterators and modernise idioms
Some checks failed
Deploy / build (push) Failing after 4s
Security Scan / security (push) Successful in 16s
- Add Children() iter.Seq on TreeNode for range-based traversal
- Add RegisteredCommands() iter.Seq on command registry (mutex-safe)
- Add Regions()/Slots() iterators on Composite layout
- Add Tasks()/Snapshots() iterators on TaskTracker (mutex-safe)
- Use strings.FieldsSeq, strings.SplitSeq in parseMultiSelection
- Use range-over-int where applicable

Co-Authored-By: Gemini <noreply@google.com>
Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-23 04:57:24 +00:00
Snider
38765962f8 feat(cli): add Int64Flag, DurationFlag helpers; remove NewPassthrough
Add Int64Flag and DurationFlag to the flag helper set for commands
needing int64 seeds and time.Duration intervals. Remove NewPassthrough
which enabled the anti-pattern of bypassing cobra flag parsing with
stdlib flag.FlagSet.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-23 03:32:39 +00:00
Snider
0006650a10 docs: remove duplicate plan files
Some checks failed
Deploy / build (push) Failing after 4s
Security Scan / security (push) Successful in 18s
Delete plans that exist as canonical copies in core repo:
- core-ide-job-runner-plan (canonical in core)
- mcp-integration (canonical in core)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:46:24 +00:00
Claude
9b568bd921
chore: refresh go.sum after upstream tag updates
Some checks failed
Deploy / build (push) Failing after 5s
Security Scan / security (push) Successful in 17s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:36:25 +00:00
Snider
5500c3912c feat(cli): add WithCommands lifecycle pattern for command registration
Some checks failed
Deploy / build (push) Failing after 4s
Security Scan / security (push) Successful in 19s
Main() now accepts variadic framework.Option args, allowing commands
to register through the Core lifecycle via WithCommands(). This matches
the pattern from core/go and enables LEM and other consumers to use
the same API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:01:31 +00:00
Claude
e360115b66
chore: sync workspace dependency versions
Some checks failed
Deploy / build (push) Failing after 4s
Security Scan / security (push) Successful in 17s
Run go work sync to align dependency versions across workspace.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:41:04 +00:00
Claude
73723b6fb9
docs: add Frame bubbletea upgrade design and implementation plan
Some checks failed
Deploy / build (push) Failing after 3s
Security Scan / security (push) Successful in 16s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:23:04 +00:00
Claude
f54876abb5
test(frame): add message routing edge case tests 2026-02-22 21:21:11 +00:00
Claude
cf6e4700c9
test(frame): add Navigate/Back tests with FrameModel 2026-02-22 21:20:47 +00:00
Claude
d540e5706b
test(frame): add spatial focus navigation tests 2026-02-22 21:20:24 +00:00
Claude
96b2cb6547
refactor(frame): unify String() with View() via viewLocked()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:18:55 +00:00
Claude
1c6e910251
feat(frame): replace raw ANSI runLive with tea.Program
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:16:48 +00:00
Claude
331bcd564d
feat(frame): implement tea.Model (Init, Update, View) with lipgloss layout
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:12:32 +00:00
Claude
acfbc2aaee
feat(frame): add focus management fields, Focused(), Focus(), WithKeyMap() 2026-02-22 21:07:32 +00:00
Claude
02e8343ee5
feat(frame): add KeyMap with default bindings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:03:50 +00:00
Claude
aa5cfc312d
feat(frame): add FrameModel interface and modelAdapter
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:02:53 +00:00
Claude
762eadd736
deps: add bubbletea and lipgloss for Frame upgrade
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:00:18 +00:00
Claude
6a8bd92189
feat: add pkg/cli with TUI components (#14, #15)
Some checks failed
Deploy / build (push) Failing after 4s
Security Scan / security (push) Successful in 15s
Move pkg/cli from core/go to core/cli. Includes Frame AppShell,
Stream, TaskTracker, Tree, Rich Table. Update imports to v0.0.1
tagged deps and fix openpgp import path for go-crypt split.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:42:00 +00:00
Snider
7303ba6f23 refactor: register commands through Core framework lifecycle
Some checks failed
Deploy / build (push) Failing after 4s
Security Scan / security (push) Successful in 18s
Replace init() + cli.RegisterCommands() with cli.WithCommands() passed
to cli.Main(). Commands now register as framework services and receive
the root command during OnStartup — no global state, no blank imports.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 22:08:01 +00:00
Snider
800bb91601 refactor: remove ecosystem repo imports from CLI
Some checks failed
Deploy / build (push) Failing after 3s
Security Scan / security (push) Successful in 18s
CLI now only includes its own local commands.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 21:48:49 +00:00
Snider
39d247ec9e style: clean up main.go imports
Some checks failed
Deploy / build (push) Failing after 3s
Security Scan / security (push) Successful in 15s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 21:48:07 +00:00
Snider
b6468b8e6f refactor: move 9 cmd packages to ecosystem repos
Some checks failed
Deploy / build (push) Failing after 3s
Security Scan / security (push) Successful in 15s
- cmd/go → core/go cmd/gocmd
- cmd/dev, setup, qa, docs, gitcmd, monitor → go-devops
- cmd/lab → go-ai
- cmd/workspace → go-agentic

CLI now imports commands from ecosystem repos via blank imports.
Remaining local: config, doctor, help, module, pkgcmd, plugin, session.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 21:46:24 +00:00
Snider
a9fe9fe04b refactor: move core-app, updater, vanity-import, community out of CLI
Some checks failed
Deploy / build (push) Failing after 3s
Security Scan / security (push) Successful in 15s
- cmd/core-app/ → core framework repo (workspace module)
- cmd/updater/ → go-devops
- cmd/vanity-import/ → go-devops
- cmd/community/ → go-devops
- Remove stale Taskfile tasks for moved products (ide, app, bugseti)

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 21:33:15 +00:00
Snider
1b99ea22f1 chore: remove stale docs, update installers to Forge
Some checks failed
Deploy / build (push) Failing after 3s
Security Scan / security (push) Successful in 13s
Remove: Makefile, infra.yaml, GEMINI.md, AUDIT-DEPENDENCIES.md,
ISSUES_TRIAGE.md, github-projects-recovery.md

Update install.sh and install.bat to use forge.lthn.ai/core/cli
instead of dead github.com/host-uk/core.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 21:29:21 +00:00
Snider
fb52e03f50 chore: remove duplicate Taskfile.yaml
Some checks failed
Deploy / build (push) Failing after 4s
Security Scan / security (push) Successful in 13s
Taskfile.yml has the full config; .yaml was a stale 3-line duplicate.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 21:25:34 +00:00
Snider
ad38551b4c chore: remove mkdocs.yml and stale test artifacts
Some checks failed
Deploy / build (push) Failing after 3s
Security Scan / security (push) Successful in 15s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 21:24:30 +00:00
Snider
30311db9ea refactor: move scripts to go-ml and go-agent
Some checks failed
Deploy / build (push) Failing after 4s
Security Scan / security (push) Failing after 8s
- setup-ubuntu.sh → go-ml/scripts/
- agent-runner.sh, agent-setup.sh, gemini-batch-runner.sh, ethics-ab/ → go-agent/scripts/

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 21:20:12 +00:00
Snider
5fc0c89542 refactor: move agent configs and CI pipelines to proper repos
Some checks failed
Deploy / build (push) Failing after 4s
Security Scan / security (push) Successful in 15s
- .claude/, .gemini/ → go-agent (agent settings)
- .woodpecker/ → go-devops (Woodpecker CI pipelines)

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 21:18:32 +00:00
Snider
236c498e76 refactor: move playbooks, plans, and RAG tools to proper repos
Some checks failed
Deploy / build (push) Failing after 4s
Security Scan / security (push) Successful in 15s
- playbooks/ → go-devops/playbooks/ (Ansible playbooks)
- tasks/plans/ → go-devops/docs/plans/ (design/impl docs)
- tools/rag/ → go-rag/tools/rag/ (Python RAG scripts)

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 21:16:50 +00:00
Snider
260dca0999 refactor: move PHP docker files to core-php, keep only CLI Dockerfile
Some checks failed
Deploy / build (push) Failing after 4s
Security Scan / security (push) Successful in 14s
Move Dockerfile.app, Dockerfile.web, nginx/, php/, and docker-compose
to core-php where they belong. Promote Dockerfile.core to root Dockerfile.
Simplify deploy workflow to only build the CLI image.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 21:14:28 +00:00
Snider
c84ce5265f refactor(ci): use reusable docker-publish workflow, switch to Docker Hub
Some checks failed
Deploy / Test (push) Failing after 1s
Deploy / Build App Image (push) Has been skipped
Deploy / Build Web Image (push) Has been skipped
Deploy / Build Core Image (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Security Scan / security (push) Successful in 16s
Replace inline docker build/push jobs with shared workflow from go-devops.
Add proper multi-stage Dockerfile.core (was inline heredoc).
Switch registry from dappco.re/osi to docker.io/lthn/.

Requires org secrets: REGISTRY_USER, REGISTRY_TOKEN

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 21:04:44 +00:00
Snider
f72a7f603f chore(ci): use reusable security scan from go-devops
Some checks failed
Deploy / Test (push) Failing after 1s
Deploy / Build App Image (push) Has been skipped
Deploy / Build Web Image (push) Has been skipped
Security Scan / security (push) Successful in 21s
Deploy / Build Core Image (push) Failing after 1m42s
Deploy / Deploy to Production (push) Has been skipped
Replace inline govulncheck/gitleaks/trivy with shared workflow call.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 21:02:37 +00:00
272 changed files with 2628 additions and 55328 deletions

View file

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

12
.editorconfig Normal file
View file

@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
indent_style = tab
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
[*.{md,yml,yaml,json,txt}]
indent_style = space
indent_size = 2

View file

@ -1,12 +1,4 @@
# 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)
# Core CLI Docker image build + push
name: Deploy
@ -15,134 +7,11 @@ 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:
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.26"
- 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
RUN adduser -D -h /home/core core
USER 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 }}"
build:
uses: core/go-devops/.forgejo/workflows/docker-publish.yml@main
with:
image: lthn/core
dockerfile: Dockerfile
registry: docker.io
secrets: inherit

View file

@ -1,6 +1,5 @@
# 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)
# Security scanning via reusable workflow
# Source: core/go-devops/.forgejo/workflows/security-scan.yml
name: Security Scan
@ -11,50 +10,6 @@ on:
branches: [main]
jobs:
govulncheck:
name: Go Vulnerability Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26'
- 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: |
set -euo pipefail
GITLEAKS_VERSION="8.24.3"
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH_SUFFIX="x64" ;;
aarch64) ARCH_SUFFIX="arm64" ;;
*) echo "Unsupported arch: $ARCH"; exit 1 ;;
esac
URL="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_${ARCH_SUFFIX}.tar.gz"
echo "Downloading gitleaks v${GITLEAKS_VERSION} for ${ARCH_SUFFIX}..."
curl -fsSL "$URL" | tar xz -C /usr/local/bin gitleaks
gitleaks version
- 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 .
security:
uses: core/go-devops/.forgejo/workflows/security-scan.yml@main
secrets: inherit

View file

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

2
.gitignore vendored
View file

@ -17,8 +17,10 @@ dist/
tasks
/cli
/core
local.test
/i18n-validate
.angular/
patch_cov.*
go.work.sum
.kb

22
.golangci.yml Normal file
View file

@ -0,0 +1,22 @@
run:
timeout: 5m
go: "1.26"
linters:
enable:
- govet
- errcheck
- staticcheck
- unused
- gosimple
- ineffassign
- typecheck
- gocritic
- gofmt
disable:
- exhaustive
- wrapcheck
issues:
exclude-use-default: false
max-same-issues: 0

View file

@ -1,52 +0,0 @@
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]

View file

@ -1,21 +0,0 @@
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 ./...

View file

@ -1,143 +0,0 @@
# 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*

35
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,35 @@
# Contributing
Thank you for your interest in contributing!
## Requirements
- **Go Version**: 1.26 or higher is required.
- **Tools**: `golangci-lint` and `task` (Taskfile.dev) are recommended.
## Development Workflow
1. **Testing**: Ensure all tests pass before submitting changes.
```bash
go test ./...
```
2. **Code Style**: All code must follow standard Go formatting.
```bash
gofmt -w .
go vet ./...
```
3. **Linting**: We use `golangci-lint` to maintain code quality.
```bash
golangci-lint run ./...
```
## Commit Message Format
We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
- `feat`: A new feature
- `fix`: A bug fix
- `docs`: Documentation changes
- `refactor`: A code change that neither fixes a bug nor adds a feature
- `chore`: Changes to the build process or auxiliary tools and libraries
Example: `feat: add new endpoint for health check`
## License
By contributing to this project, you agree that your contributions will be licensed under the **European Union Public Licence (EUPL-1.2)**.

21
Dockerfile Normal file
View file

@ -0,0 +1,21 @@
# 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"]

View file

@ -1,55 +0,0 @@
# 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.

View file

@ -1,166 +0,0 @@
# 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)

View file

@ -1,20 +0,0 @@
.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"

View file

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

View file

@ -148,101 +148,6 @@ 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"

View file

@ -1,602 +0,0 @@
<!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,10 +2,6 @@ package config
import "forge.lthn.ai/core/cli/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

@ -1,100 +0,0 @@
# 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.

View file

@ -1,37 +0,0 @@
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

@ -1,48 +0,0 @@
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()
}
}

View file

@ -1,52 +0,0 @@
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
}

View file

@ -1,167 +0,0 @@
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
}

View file

@ -1,92 +0,0 @@
module forge.lthn.ai/core/go/cmd/core-app
go 1.26.0
require (
github.com/dunglas/frankenphp v1.11.2
github.com/wailsapp/wails/v3 v3.0.0-alpha.64
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/RoaringBitmap/roaring/v2 v2.14.4 // 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/bits-and-blooms/bitset v1.24.4 // 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/dunglas/mercure v0.21.8 // indirect
github.com/dunglas/skipfilter v1.0.0 // indirect
github.com/e-dant/watcher v0.0.0-20260202035023-10268e78355f // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.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/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gofrs/uuid/v5 v5.4.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/handlers v1.5.2 // indirect
github.com/gorilla/mux v1.8.1 // 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/v2 v2.3.0 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // 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.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/cors v1.11.1 // indirect
github.com/sagikazarmark/locafero v0.12.0 // 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/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/unrolled/secure v1.17.0 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace forge.lthn.ai/core/go => ../..

View file

@ -1,235 +0,0 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145 h1:1yw6O62BReQ+uA1oyk9XaQTvLhcoHWmoQAgXmDFXpIY=
github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145/go.mod h1:877WBceefKn14QwVVn4xRFUsHsZb9clICgdeTj4XsUg=
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/RoaringBitmap/roaring/v2 v2.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw=
github.com/RoaringBitmap/roaring/v2 v2.14.4/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+aoEUopyv5iH0u/+wbY=
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/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
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/dunglas/frankenphp v1.11.2 h1:EmigvWr7zH192r4RJQhAmEnEcP7Gjl7FX2PY9Hi4/j4=
github.com/dunglas/frankenphp v1.11.2/go.mod h1:8rGuTpgIFerStA3dhh1CM8MjxqIJ8uMdwT59Sfhp+Lw=
github.com/dunglas/mercure v0.21.8 h1:D+SxSq0VqdB29lfMXrsvDkFvq/cTL94aKCC0R4heKV0=
github.com/dunglas/mercure v0.21.8/go.mod h1:kt4RJpixJOcPN+x9Z53VBhpJYSdyEEzuu9/99vJIocQ=
github.com/dunglas/skipfilter v1.0.0 h1:JG9SgGg4n6BlFwuTYzb9RIqjH7PfwszvWehanrYWPF4=
github.com/dunglas/skipfilter v1.0.0/go.mod h1:ryhr8j7CAHSjzeN7wI6YEuwoArQ3OQmRqWWVCEAfb9w=
github.com/e-dant/watcher v0.0.0-20260202035023-10268e78355f h1:UDB5nhFRW7IOOpLk/eP1UGj7URmPimFGV+01/EG9qR8=
github.com/e-dant/watcher v0.0.0-20260202035023-10268e78355f/go.mod h1:PmV4IVmBJVqT2NcfTGN4+sZ+qGe3PA0qkphAtOHeFG0=
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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
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/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
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/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
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/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
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/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w=
github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
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.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
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/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
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/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
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=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/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=

View file

@ -1,137 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 76 B

View file

@ -1,24 +0,0 @@
// 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.

Before

Width:  |  Height:  |  Size: 76 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 B

View file

@ -1,13 +0,0 @@
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

View file

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

View file

@ -1,46 +0,0 @@
<?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

@ -1,27 +0,0 @@
<?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

@ -1,111 +0,0 @@
<?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

@ -1,85 +0,0 @@
<?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

@ -1,93 +0,0 @@
<?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

@ -1,125 +0,0 @@
<?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

@ -1,60 +0,0 @@
<?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

@ -1,43 +0,0 @@
<?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

@ -1,26 +0,0 @@
<?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

@ -1,36 +0,0 @@
<?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

@ -1,29 +0,0 @@
<?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

@ -1,45 +0,0 @@
<?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

@ -1,183 +0,0 @@
<?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

@ -1,155 +0,0 @@
<?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

@ -1,302 +0,0 @@
<?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

@ -1,21 +0,0 @@
#!/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

@ -1,21 +0,0 @@
<?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

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

View file

@ -1,29 +0,0 @@
{
"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"
]
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,19 +0,0 @@
<?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

@ -1,21 +0,0 @@
<?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

@ -1,25 +0,0 @@
<?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

@ -1,51 +0,0 @@
<?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

@ -1,25 +0,0 @@
<?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

@ -1,22 +0,0 @@
<?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

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

View file

@ -1,27 +0,0 @@
<?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

@ -1,31 +0,0 @@
<?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');
}
};

View file

@ -1,75 +0,0 @@
<?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('agent_allowances', function (Blueprint $table) {
$table->id();
$table->string('agent_id')->unique();
$table->bigInteger('daily_token_limit')->default(0);
$table->integer('daily_job_limit')->default(0);
$table->integer('concurrent_jobs')->default(1);
$table->integer('max_job_duration_minutes')->default(0);
$table->json('model_allowlist')->nullable();
$table->timestamps();
});
Schema::create('quota_usage', function (Blueprint $table) {
$table->id();
$table->string('agent_id')->index();
$table->bigInteger('tokens_used')->default(0);
$table->integer('jobs_started')->default(0);
$table->integer('active_jobs')->default(0);
$table->date('period_date')->index();
$table->timestamps();
$table->unique(['agent_id', 'period_date']);
});
Schema::create('model_quotas', function (Blueprint $table) {
$table->id();
$table->string('model')->unique();
$table->bigInteger('daily_token_budget')->default(0);
$table->integer('hourly_rate_limit')->default(0);
$table->bigInteger('cost_ceiling')->default(0);
$table->timestamps();
});
Schema::create('usage_reports', function (Blueprint $table) {
$table->id();
$table->string('agent_id')->index();
$table->string('job_id')->index();
$table->string('model')->nullable();
$table->bigInteger('tokens_in')->default(0);
$table->bigInteger('tokens_out')->default(0);
$table->string('event');
$table->timestamp('reported_at');
$table->timestamps();
});
Schema::create('repo_limits', function (Blueprint $table) {
$table->id();
$table->string('repo')->unique();
$table->integer('max_daily_prs')->default(0);
$table->integer('max_daily_issues')->default(0);
$table->integer('cooldown_after_failure_minutes')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('repo_limits');
Schema::dropIfExists('usage_reports');
Schema::dropIfExists('model_quotas');
Schema::dropIfExists('quota_usage');
Schema::dropIfExists('agent_allowances');
}
};

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
(require_once __DIR__.'/../bootstrap/app.php')
->handleRequest(Request::capture());

View file

@ -1,105 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $title ?? 'Agentic Dashboard' }} Core</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
surface: { DEFAULT: '#0d1117', raised: '#161b22', overlay: '#21262d' },
border: { DEFAULT: '#30363d', subtle: '#21262d' },
accent: { DEFAULT: '#39d0d8', dim: '#1b6b6f' },
success: '#238636',
warning: '#d29922',
danger: '#da3633',
muted: '#8b949e',
},
},
},
}
</script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
<style>
[x-cloak] { display: none !important; }
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
.heartbeat { animation: pulse-dot 2s ease-in-out infinite; }
.scrollbar-thin::-webkit-scrollbar { width: 6px; }
.scrollbar-thin::-webkit-scrollbar-track { background: transparent; }
.scrollbar-thin::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; }
</style>
@livewireStyles
</head>
<body class="h-full bg-surface text-gray-200 antialiased">
<div class="flex h-full" x-data="{ sidebarOpen: true }">
{{-- Sidebar --}}
<aside class="flex flex-col w-56 border-r border-border bg-surface-raised shrink-0 transition-all"
:class="sidebarOpen ? 'w-56' : 'w-16'">
<div class="flex items-center gap-2 px-4 h-14 border-b border-border">
<svg class="w-6 h-6 text-accent shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<span class="font-semibold text-sm tracking-wide" x-show="sidebarOpen" x-cloak>Agentic</span>
</div>
<nav class="flex-1 py-2 space-y-0.5 px-2">
<a href="{{ route('dashboard') }}"
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
<span x-show="sidebarOpen">Dashboard</span>
</a>
<a href="{{ route('dashboard.agents') }}"
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard.agents') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span x-show="sidebarOpen">Agent Fleet</span>
</a>
<a href="{{ route('dashboard.jobs') }}"
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard.jobs') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
<span x-show="sidebarOpen">Job Queue</span>
</a>
<a href="{{ route('dashboard.activity') }}"
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard.activity') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
<span x-show="sidebarOpen">Activity</span>
</a>
</nav>
<div class="border-t border-border p-2">
<button @click="sidebarOpen = !sidebarOpen"
class="flex items-center justify-center w-full px-3 py-2 text-muted hover:text-white rounded-md hover:bg-surface-overlay transition">
<svg class="w-4 h-4 transition-transform" :class="sidebarOpen ? '' : 'rotate-180'" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7"/></svg>
</button>
</div>
</aside>
{{-- Main content --}}
<main class="flex-1 overflow-auto">
<header class="sticky top-0 z-10 flex items-center justify-between h-14 px-6 border-b border-border bg-surface/80 backdrop-blur">
<h1 class="text-sm font-semibold">{{ $title ?? 'Dashboard' }}</h1>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2 text-xs text-muted"
x-data="{ connected: true }"
x-init="
setInterval(() => {
connected = navigator.onLine;
}, 3000)
">
<span class="w-2 h-2 rounded-full heartbeat"
:class="connected ? 'bg-green-500' : 'bg-red-500'"></span>
<span x-text="connected ? 'Connected' : 'Disconnected'"></span>
</div>
<span class="text-xs text-muted font-mono">{{ now()->format('H:i') }}</span>
</div>
</header>
<div class="p-6">
{{ $slot }}
</div>
</main>
</div>
@livewireScripts
</body>
</html>

View file

@ -1,107 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Core App</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0d1117;
color: #e6edf3;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 32px;
padding: 32px;
}
.card {
background: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
padding: 48px;
text-align: center;
max-width: 600px;
width: 100%;
}
h1 { font-size: 32px; margin-bottom: 8px; }
h2 { font-size: 20px; margin-bottom: 16px; color: #8b949e; font-weight: 400; }
.accent { color: #39d0d8; }
.subtitle { color: #8b949e; font-size: 16px; margin-bottom: 24px; }
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 24px;
text-align: left;
}
.info-item {
background: #21262d;
border: 1px solid #30363d;
border-radius: 8px;
padding: 12px 16px;
}
.info-item__label { font-size: 11px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; }
.info-item__value { font-size: 14px; margin-top: 4px; font-family: monospace; }
.badge {
display: inline-block;
background: #238636;
color: #fff;
border-radius: 12px;
padding: 4px 12px;
font-size: 12px;
font-weight: 600;
margin-top: 20px;
}
.counter { text-align: center; }
.counter__display {
font-size: 72px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #39d0d8;
line-height: 1;
margin-bottom: 24px;
}
.counter__controls {
display: flex;
gap: 16px;
justify-content: center;
}
.counter__hint {
margin-top: 16px;
font-size: 12px;
color: #8b949e;
}
.btn {
appearance: none;
border: 1px solid #30363d;
border-radius: 8px;
padding: 12px 32px;
font-size: 20px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.btn:active { transform: scale(0.96); }
.btn--primary {
background: #238636;
color: #fff;
border-color: #2ea043;
}
.btn--primary:hover { background: #2ea043; }
.btn--secondary {
background: #21262d;
color: #e6edf3;
}
.btn--secondary:hover { background: #30363d; }
</style>
@livewireStyles
</head>
<body>
{{ $slot }}
@livewireScripts
</body>
</html>

View file

@ -1,3 +0,0 @@
<x-dashboard-layout title="Live Activity">
<livewire:dashboard.activity-feed />
</x-dashboard-layout>

View file

@ -1,3 +0,0 @@
<x-dashboard-layout title="Agent Fleet">
<livewire:dashboard.agent-fleet />
</x-dashboard-layout>

View file

@ -1,34 +0,0 @@
<x-dashboard-layout title="Dashboard">
{{-- Metrics overview at top --}}
<section class="mb-8">
<livewire:dashboard.metrics />
</section>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
{{-- Left column: Agent fleet + Human actions --}}
<div class="xl:col-span-2 space-y-6">
<section>
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Agent Fleet</h2>
<livewire:dashboard.agent-fleet />
</section>
<section>
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Job Queue</h2>
<livewire:dashboard.job-queue />
</section>
</div>
{{-- Right column: Actions + Activity --}}
<div class="space-y-6">
<section>
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Human Actions</h2>
<livewire:dashboard.human-actions />
</section>
<section>
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Live Activity</h2>
<livewire:dashboard.activity-feed />
</section>
</div>
</div>
</x-dashboard-layout>

View file

@ -1,3 +0,0 @@
<x-dashboard-layout title="Job Queue">
<livewire:dashboard.job-queue />
</x-dashboard-layout>

View file

@ -1,8 +0,0 @@
<div class="counter">
<div class="counter__display">{{ $count }}</div>
<div class="counter__controls">
<button wire:click="decrement" class="btn btn--secondary">&minus;</button>
<button wire:click="increment" class="btn btn--primary">+</button>
</div>
<p class="counter__hint">Livewire {{ \Livewire\Livewire::VERSION }} &middot; Server-rendered, no page reload</p>
</div>

View file

@ -1,72 +0,0 @@
<div wire:poll.3s="loadEntries">
{{-- Filters --}}
<div class="flex flex-wrap items-center gap-3 mb-4">
<select wire:model.live="agentFilter"
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
<option value="all">All agents</option>
<option value="Athena">Athena</option>
<option value="Virgil">Virgil</option>
<option value="Clotho">Clotho</option>
<option value="Charon">Charon</option>
</select>
<select wire:model.live="typeFilter"
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
<option value="all">All types</option>
<option value="code_write">Code write</option>
<option value="tool_call">Tool call</option>
<option value="test_run">Test run</option>
<option value="pr_created">PR created</option>
<option value="git_push">Git push</option>
<option value="question">Question</option>
</select>
<label class="flex items-center gap-2 text-xs text-muted cursor-pointer">
<input type="checkbox" wire:model.live="showOnlyQuestions"
class="rounded border-border bg-surface-overlay text-accent focus:ring-accent">
Waiting for answer only
</label>
</div>
{{-- Feed --}}
<div class="space-y-2 max-h-[600px] overflow-y-auto scrollbar-thin">
@forelse ($this->filteredEntries as $entry)
<div class="bg-surface-raised border rounded-lg px-4 py-3 transition
{{ $entry['is_question'] ? 'border-yellow-500/50 bg-yellow-500/5' : 'border-border' }}">
<div class="flex items-start gap-3">
{{-- Type icon --}}
@php
$typeIcons = [
'code_write' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>',
'tool_call' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>',
'test_run' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>',
'pr_created' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/>',
'git_push' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/>',
'question' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01"/>',
];
$iconPath = $typeIcons[$entry['type']] ?? $typeIcons['tool_call'];
$iconColor = $entry['is_question'] ? 'text-yellow-400' : 'text-muted';
@endphp
<svg class="w-4 h-4 mt-0.5 shrink-0 {{ $iconColor }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">{!! $iconPath !!}</svg>
{{-- Content --}}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5">
<span class="text-xs font-semibold text-gray-300">{{ $entry['agent'] }}</span>
<span class="text-[10px] text-muted font-mono">{{ $entry['job'] }}</span>
@if ($entry['is_question'])
<span class="text-[10px] px-1.5 py-0.5 rounded bg-yellow-500/20 text-yellow-400 font-medium">NEEDS ANSWER</span>
@endif
</div>
<p class="text-xs text-gray-400 leading-relaxed">{{ $entry['message'] }}</p>
</div>
{{-- Timestamp --}}
<span class="text-[11px] text-muted shrink-0">
{{ \Carbon\Carbon::parse($entry['timestamp'])->diffForHumans(short: true) }}
</span>
</div>
</div>
@empty
<div class="text-center py-8 text-muted text-sm">No activity matching filters.</div>
@endforelse
</div>
</div>

View file

@ -1,58 +0,0 @@
<div wire:poll.5s="loadAgents">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
@foreach ($agents as $agent)
<div wire:click="selectAgent('{{ $agent['id'] }}')"
class="bg-surface-raised border rounded-lg p-4 cursor-pointer transition hover:border-accent
{{ $selectedAgent === $agent['id'] ? 'border-accent' : 'border-border' }}">
{{-- Header --}}
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full heartbeat
{{ $agent['heartbeat'] === 'green' ? 'bg-green-500' : ($agent['heartbeat'] === 'yellow' ? 'bg-yellow-500' : 'bg-red-500') }}"></span>
<span class="font-semibold text-sm">{{ $agent['name'] }}</span>
</div>
<span class="text-[10px] px-2 py-0.5 rounded-full font-medium uppercase tracking-wider
{{ $agent['status'] === 'working' ? 'bg-blue-500/20 text-blue-400' : ($agent['status'] === 'idle' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400') }}">
{{ $agent['status'] }}
</span>
</div>
{{-- Info --}}
<div class="space-y-1.5 text-xs text-muted">
<div class="flex justify-between">
<span>Host</span>
<span class="text-gray-300 font-mono">{{ $agent['host'] }}</span>
</div>
<div class="flex justify-between">
<span>Model</span>
<span class="text-gray-300 font-mono text-[11px]">{{ $agent['model'] }}</span>
</div>
<div class="flex justify-between">
<span>Uptime</span>
<span class="text-gray-300">{{ $agent['uptime'] }}</span>
</div>
@if ($agent['job'])
<div class="flex justify-between">
<span>Job</span>
<span class="text-accent text-[11px]">{{ $agent['job'] }}</span>
</div>
@endif
</div>
{{-- Expanded detail --}}
@if ($selectedAgent === $agent['id'])
<div class="mt-3 pt-3 border-t border-border space-y-1.5 text-xs text-muted">
<div class="flex justify-between">
<span>Tokens today</span>
<span class="text-gray-300">{{ number_format($agent['tokens_today']) }}</span>
</div>
<div class="flex justify-between">
<span>Jobs completed</span>
<span class="text-gray-300">{{ $agent['jobs_completed'] }}</span>
</div>
</div>
@endif
</div>
@endforeach
</div>
</div>

View file

@ -1,92 +0,0 @@
<div wire:poll.3s="loadPending">
{{-- Pending questions --}}
@if (count($pendingQuestions) > 0)
<div class="mb-6">
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-yellow-500 heartbeat"></span>
Agent Questions ({{ count($pendingQuestions) }})
</h3>
<div class="space-y-3">
@foreach ($pendingQuestions as $q)
<div class="bg-yellow-500/5 border border-yellow-500/30 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold text-yellow-400">{{ $q['agent'] }}</span>
<span class="text-[10px] text-muted font-mono">{{ $q['job'] }}</span>
<span class="text-[10px] text-muted">{{ \Carbon\Carbon::parse($q['asked_at'])->diffForHumans(short: true) }}</span>
</div>
<p class="text-sm text-gray-300 mb-2">{{ $q['question'] }}</p>
@if (!empty($q['context']))
<p class="text-xs text-muted mb-3">{{ $q['context'] }}</p>
@endif
@if ($answeringId === $q['id'])
<div class="mt-3">
<textarea wire:model="answerText"
rows="3"
placeholder="Type your answer..."
class="w-full bg-surface-overlay border border-border rounded-md px-3 py-2 text-sm text-gray-300 placeholder-muted focus:border-accent focus:outline-none resize-none"></textarea>
<div class="flex gap-2 mt-2">
<button wire:click="submitAnswer"
class="px-3 py-1.5 text-xs font-medium rounded bg-accent text-surface hover:opacity-90 transition">
Send Answer
</button>
<button wire:click="cancelAnswer"
class="px-3 py-1.5 text-xs font-medium rounded bg-surface-overlay text-muted hover:text-white border border-border transition">
Cancel
</button>
</div>
</div>
@else
<button wire:click="startAnswer('{{ $q['id'] }}')"
class="px-3 py-1.5 text-xs font-medium rounded bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30 transition">
Answer
</button>
@endif
</div>
@endforeach
</div>
</div>
@endif
{{-- Review gates --}}
@if (count($reviewGates) > 0)
<div>
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-purple-500 heartbeat"></span>
Review Gates ({{ count($reviewGates) }})
</h3>
<div class="space-y-3">
@foreach ($reviewGates as $gate)
<div class="bg-surface-raised border border-purple-500/30 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold text-purple-400">{{ $gate['agent'] }}</span>
<span class="text-[10px] text-muted font-mono">{{ $gate['job'] }}</span>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-400 font-medium uppercase">{{ str_replace('_', ' ', $gate['type']) }}</span>
</div>
<p class="text-sm font-medium text-gray-300 mb-1">{{ $gate['title'] }}</p>
<p class="text-xs text-muted mb-3">{{ $gate['description'] }}</p>
<div class="flex gap-2">
<button wire:click="approveGate('{{ $gate['id'] }}')"
class="px-3 py-1.5 text-xs font-medium rounded bg-green-500/20 text-green-400 hover:bg-green-500/30 transition">
Approve
</button>
<button wire:click="rejectGate('{{ $gate['id'] }}')"
class="px-3 py-1.5 text-xs font-medium rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 transition">
Reject
</button>
</div>
</div>
@endforeach
</div>
</div>
@endif
@if (count($pendingQuestions) === 0 && count($reviewGates) === 0)
<div class="text-center py-12 text-muted">
<svg class="w-8 h-8 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="text-sm">No pending actions. All agents are autonomous.</p>
</div>
@endif
</div>

View file

@ -1,98 +0,0 @@
<div wire:poll.5s="loadJobs">
{{-- Filters --}}
<div class="flex flex-wrap gap-3 mb-4">
<select wire:model.live="statusFilter"
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
<option value="all">All statuses</option>
<option value="queued">Queued</option>
<option value="in_progress">In Progress</option>
<option value="review">Review</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
</select>
<select wire:model.live="agentFilter"
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
<option value="all">All agents</option>
<option value="Athena">Athena</option>
<option value="Virgil">Virgil</option>
<option value="Clotho">Clotho</option>
<option value="Charon">Charon</option>
</select>
</div>
{{-- Table --}}
<div class="bg-surface-raised border border-border rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border text-xs text-muted uppercase tracking-wider">
<th class="text-left px-4 py-3 font-medium">Job</th>
<th class="text-left px-4 py-3 font-medium">Issue</th>
<th class="text-left px-4 py-3 font-medium">Agent</th>
<th class="text-left px-4 py-3 font-medium">Status</th>
<th class="text-left px-4 py-3 font-medium">Priority</th>
<th class="text-left px-4 py-3 font-medium">Queued</th>
<th class="text-right px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
@forelse ($this->filteredJobs as $job)
<tr class="hover:bg-surface-overlay/50 transition">
<td class="px-4 py-3">
<div class="font-mono text-xs text-muted">{{ $job['id'] }}</div>
<div class="text-xs text-gray-300 mt-0.5 truncate max-w-[200px]">{{ $job['title'] }}</div>
</td>
<td class="px-4 py-3">
<span class="text-accent font-mono text-xs">{{ $job['issue'] }}</span>
<div class="text-[11px] text-muted">{{ $job['repo'] }}</div>
</td>
<td class="px-4 py-3 text-xs">
{{ $job['agent'] ?? '—' }}
</td>
<td class="px-4 py-3">
@php
$statusColors = [
'queued' => 'bg-yellow-500/20 text-yellow-400',
'in_progress' => 'bg-blue-500/20 text-blue-400',
'review' => 'bg-purple-500/20 text-purple-400',
'completed' => 'bg-green-500/20 text-green-400',
'failed' => 'bg-red-500/20 text-red-400',
'cancelled' => 'bg-gray-500/20 text-gray-400',
];
@endphp
<span class="text-[10px] px-2 py-0.5 rounded-full font-medium uppercase tracking-wider {{ $statusColors[$job['status']] ?? '' }}">
{{ str_replace('_', ' ', $job['status']) }}
</span>
</td>
<td class="px-4 py-3">
<span class="text-xs font-mono text-muted">P{{ $job['priority'] }}</span>
</td>
<td class="px-4 py-3 text-xs text-muted">
{{ \Carbon\Carbon::parse($job['queued_at'])->diffForHumans(short: true) }}
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
@if (in_array($job['status'], ['queued', 'in_progress']))
<button wire:click="cancelJob('{{ $job['id'] }}')"
class="text-[11px] px-2 py-1 rounded bg-red-500/10 text-red-400 hover:bg-red-500/20 transition">
Cancel
</button>
@endif
@if (in_array($job['status'], ['failed', 'cancelled']))
<button wire:click="retryJob('{{ $job['id'] }}')"
class="text-[11px] px-2 py-1 rounded bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition">
Retry
</button>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-8 text-center text-muted text-sm">No jobs match the selected filters.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>

View file

@ -1,113 +0,0 @@
<div wire:poll.10s="loadMetrics">
{{-- Stat cards --}}
<div class="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4 mb-6">
@php
$statCards = [
['label' => 'Jobs Completed', 'value' => $stats['jobs_completed'], 'icon' => 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z', 'color' => 'text-green-400'],
['label' => 'PRs Merged', 'value' => $stats['prs_merged'], 'icon' => 'M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4', 'color' => 'text-purple-400'],
['label' => 'Tokens Used', 'value' => number_format($stats['tokens_used']), 'icon' => 'M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z', 'color' => 'text-blue-400'],
['label' => 'Cost Today', 'value' => '$' . number_format($stats['cost_today'], 2), 'icon' => 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z', 'color' => 'text-yellow-400'],
['label' => 'Active Agents', 'value' => $stats['active_agents'], 'icon' => 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z', 'color' => 'text-accent'],
['label' => 'Queue Depth', 'value' => $stats['queue_depth'], 'icon' => 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', 'color' => 'text-orange-400'],
];
@endphp
@foreach ($statCards as $card)
<div class="bg-surface-raised border border-border rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 {{ $card['color'] }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ $card['icon'] }}"/>
</svg>
<span class="text-[11px] text-muted uppercase tracking-wider">{{ $card['label'] }}</span>
</div>
<div class="text-xl font-bold font-mono {{ $card['color'] }}">{{ $card['value'] }}</div>
</div>
@endforeach
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Budget gauge --}}
<div class="bg-surface-raised border border-border rounded-lg p-5">
<h3 class="text-sm font-semibold mb-4">Budget</h3>
<div class="flex items-end gap-3 mb-3">
<span class="text-3xl font-bold font-mono text-accent">${{ number_format($budgetUsed, 2) }}</span>
<span class="text-sm text-muted mb-1">/ ${{ number_format($budgetLimit, 2) }}</span>
</div>
@php
$pct = $budgetLimit > 0 ? min(100, ($budgetUsed / $budgetLimit) * 100) : 0;
$barColor = $pct > 80 ? 'bg-red-500' : ($pct > 60 ? 'bg-yellow-500' : 'bg-accent');
@endphp
<div class="w-full h-3 bg-surface-overlay rounded-full overflow-hidden">
<div class="{{ $barColor }} h-full rounded-full transition-all duration-500" style="width: {{ $pct }}%"></div>
</div>
<div class="text-xs text-muted mt-2">{{ number_format($pct, 0) }}% of daily budget used</div>
</div>
{{-- Cost breakdown by model --}}
<div class="bg-surface-raised border border-border rounded-lg p-5">
<h3 class="text-sm font-semibold mb-4">Cost by Model</h3>
<div class="space-y-3">
@foreach ($costBreakdown as $model)
@php
$modelPct = $budgetUsed > 0 ? ($model['cost'] / $budgetUsed) * 100 : 0;
$modelColors = [
'claude-opus-4-6' => 'bg-purple-500',
'claude-sonnet-4-5' => 'bg-blue-500',
'claude-haiku-4-5' => 'bg-green-500',
];
$barCol = $modelColors[$model['model']] ?? 'bg-gray-500';
@endphp
<div>
<div class="flex items-center justify-between text-xs mb-1">
<span class="font-mono text-gray-300">{{ $model['model'] }}</span>
<span class="text-muted">${{ number_format($model['cost'], 2) }} ({{ number_format($model['tokens']) }} tokens)</span>
</div>
<div class="w-full h-2 bg-surface-overlay rounded-full overflow-hidden">
<div class="{{ $barCol }} h-full rounded-full transition-all duration-500" style="width: {{ $modelPct }}%"></div>
</div>
</div>
@endforeach
</div>
</div>
</div>
{{-- Throughput chart --}}
<div class="bg-surface-raised border border-border rounded-lg p-5 mt-6"
x-data="{
chart: null,
init() {
this.chart = new ApexCharts(this.$refs.chart, {
chart: {
type: 'area',
height: 240,
background: 'transparent',
toolbar: { show: false },
zoom: { enabled: false },
},
theme: { mode: 'dark' },
colors: ['#39d0d8', '#8b5cf6'],
series: [
{ name: 'Jobs', data: {{ json_encode(array_column($throughputData, 'jobs')) }} },
{ name: 'Tokens (k)', data: {{ json_encode(array_map(fn($t) => round($t / 1000, 1), array_column($throughputData, 'tokens'))) }} },
],
xaxis: {
categories: {{ json_encode(array_column($throughputData, 'hour')) }},
labels: { style: { colors: '#8b949e', fontSize: '11px' } },
},
yaxis: [
{ labels: { style: { colors: '#39d0d8' } }, title: { text: 'Jobs', style: { color: '#39d0d8' } } },
{ opposite: true, labels: { style: { colors: '#8b5cf6' } }, title: { text: 'Tokens (k)', style: { color: '#8b5cf6' } } },
],
grid: { borderColor: '#21262d', strokeDashArray: 3 },
stroke: { curve: 'smooth', width: 2 },
fill: { type: 'gradient', gradient: { opacityFrom: 0.3, opacityTo: 0.05 } },
dataLabels: { enabled: false },
legend: { labels: { colors: '#8b949e' } },
tooltip: { theme: 'dark' },
});
this.chart.render();
}
}">
<h3 class="text-sm font-semibold mb-4">Throughput</h3>
<div x-ref="chart"></div>
</div>
</div>

View file

@ -1,40 +0,0 @@
<x-layout>
<div class="card">
<h1><span class="accent">Core App</span></h1>
<p class="subtitle">Laravel {{ app()->version() }} running inside a native desktop window</p>
<div class="info-grid">
<div class="info-item">
<div class="info-item__label">PHP</div>
<div class="info-item__value">{{ PHP_VERSION }}</div>
</div>
<div class="info-item">
<div class="info-item__label">Thread Safety</div>
<div class="info-item__value">{{ PHP_ZTS ? 'ZTS (Yes)' : 'NTS (No)' }}</div>
</div>
<div class="info-item">
<div class="info-item__label">SAPI</div>
<div class="info-item__value">{{ php_sapi_name() }}</div>
</div>
<div class="info-item">
<div class="info-item__label">Platform</div>
<div class="info-item__value">{{ PHP_OS }} {{ php_uname('m') }}</div>
</div>
<div class="info-item">
<div class="info-item__label">Database</div>
<div class="info-item__value">SQLite {{ \SQLite3::version()['versionString'] }}</div>
</div>
<div class="info-item">
<div class="info-item__label">Mode</div>
<div class="info-item__value">{{ env('FRANKENPHP_WORKER') ? 'Octane Worker' : 'Standard' }}</div>
</div>
</div>
<div class="badge">Single Binary &middot; No Server &middot; No Config</div>
</div>
<div class="card">
<h2>Livewire Reactivity Test</h2>
<livewire:counter />
</div>
</x-layout>

View file

@ -1,146 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\AgentAllowance;
use App\Models\ModelQuota;
use App\Models\RepoLimit;
use App\Services\AllowanceService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| Allowance API Routes
|--------------------------------------------------------------------------
|
| Endpoints for managing agent quotas, checking allowances, and recording
| usage. Protected endpoints use QuotaMiddleware for enforcement.
|
*/
// Health check for quota service
Route::get('/allowances/health', fn () => response()->json(['status' => 'ok']));
// Agent allowance CRUD
Route::prefix('allowances/agents')->group(function () {
Route::get('/', function () {
return AgentAllowance::all();
});
Route::get('/{agentId}', function (string $agentId) {
$allowance = AgentAllowance::where('agent_id', $agentId)->first();
if (! $allowance) {
return response()->json(['error' => 'not found'], 404);
}
return $allowance;
});
Route::post('/', function (Request $request) {
$validated = $request->validate([
'agent_id' => 'required|string|unique:agent_allowances,agent_id',
'daily_token_limit' => 'integer|min:0',
'daily_job_limit' => 'integer|min:0',
'concurrent_jobs' => 'integer|min:0',
'max_job_duration_minutes' => 'integer|min:0',
'model_allowlist' => 'array',
'model_allowlist.*' => 'string',
]);
return AgentAllowance::create($validated);
});
Route::put('/{agentId}', function (Request $request, string $agentId) {
$allowance = AgentAllowance::where('agent_id', $agentId)->first();
if (! $allowance) {
return response()->json(['error' => 'not found'], 404);
}
$validated = $request->validate([
'daily_token_limit' => 'integer|min:0',
'daily_job_limit' => 'integer|min:0',
'concurrent_jobs' => 'integer|min:0',
'max_job_duration_minutes' => 'integer|min:0',
'model_allowlist' => 'array',
'model_allowlist.*' => 'string',
]);
$allowance->update($validated);
return $allowance;
});
Route::delete('/{agentId}', function (string $agentId) {
AgentAllowance::where('agent_id', $agentId)->delete();
return response()->json(['status' => 'deleted']);
});
});
// Quota check endpoint
Route::get('/allowances/check/{agentId}', function (Request $request, string $agentId, AllowanceService $svc) {
$model = $request->query('model', '');
return response()->json($svc->check($agentId, $model));
});
// Usage reporting endpoint
Route::post('/allowances/usage', function (Request $request, AllowanceService $svc) {
$validated = $request->validate([
'agent_id' => 'required|string',
'job_id' => 'required|string',
'model' => 'nullable|string',
'tokens_in' => 'integer|min:0',
'tokens_out' => 'integer|min:0',
'event' => 'required|in:job_started,job_completed,job_failed,job_cancelled',
'timestamp' => 'nullable|date',
]);
$svc->recordUsage($validated);
return response()->json(['status' => 'recorded']);
});
// Daily reset endpoint
Route::post('/allowances/reset/{agentId}', function (string $agentId, AllowanceService $svc) {
$svc->resetAgent($agentId);
return response()->json(['status' => 'reset']);
});
// Model quota management
Route::prefix('allowances/models')->group(function () {
Route::get('/', fn () => ModelQuota::all());
Route::post('/', function (Request $request) {
$validated = $request->validate([
'model' => 'required|string|unique:model_quotas,model',
'daily_token_budget' => 'integer|min:0',
'hourly_rate_limit' => 'integer|min:0',
'cost_ceiling' => 'integer|min:0',
]);
return ModelQuota::create($validated);
});
Route::put('/{model}', function (Request $request, string $model) {
$quota = ModelQuota::where('model', $model)->first();
if (! $quota) {
return response()->json(['error' => 'not found'], 404);
}
$validated = $request->validate([
'daily_token_budget' => 'integer|min:0',
'hourly_rate_limit' => 'integer|min:0',
'cost_ceiling' => 'integer|min:0',
]);
$quota->update($validated);
return $quota;
});
});

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
// Agentic Dashboard
Route::get('/dashboard', fn () => view('dashboard.index'))->name('dashboard');
Route::get('/dashboard/agents', fn () => view('dashboard.agents'))->name('dashboard.agents');
Route::get('/dashboard/jobs', fn () => view('dashboard.jobs'))->name('dashboard.jobs');
Route::get('/dashboard/activity', fn () => view('dashboard.activity'))->name('dashboard.activity');

View file

@ -1,206 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Forgejo;
use App\Services\Forgejo\ForgejoClient;
use Illuminate\Support\Facades\Http;
use Orchestra\Testbench\TestCase;
use RuntimeException;
class ForgejoClientTest extends TestCase
{
private const BASE_URL = 'https://forge.test';
private const TOKEN = 'test-token-abc123';
// ---- Construction ----
public function test_constructor_good(): void
{
Http::fake();
$client = new ForgejoClient(self::BASE_URL, self::TOKEN);
$this->assertSame(self::BASE_URL, $client->baseUrl());
}
public function test_constructor_bad_empty_token(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('API token is required');
new ForgejoClient(self::BASE_URL, '');
}
// ---- GET ----
public function test_get_good(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo' => Http::response(['id' => 1, 'name' => 'repo'], 200),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$result = $client->get('/repos/owner/repo');
$this->assertSame(1, $result['id']);
$this->assertSame('repo', $result['name']);
}
public function test_get_bad_server_error(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo' => Http::response('Internal Server Error', 500),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Forgejo API error [500]');
$client->get('/repos/owner/repo');
}
// ---- POST ----
public function test_post_good(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo/issues' => Http::response(['number' => 42], 201),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$result = $client->post('/repos/owner/repo/issues', ['title' => 'Bug']);
$this->assertSame(42, $result['number']);
}
// ---- PATCH ----
public function test_patch_good(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo/issues/1' => Http::response(['state' => 'closed'], 200),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$result = $client->patch('/repos/owner/repo/issues/1', ['state' => 'closed']);
$this->assertSame('closed', $result['state']);
}
// ---- PUT ----
public function test_put_good(): void
{
Http::fake([
'forge.test/api/v1/teams/5/members/alice' => Http::response([], 204),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$result = $client->put('/teams/5/members/alice');
$this->assertIsArray($result);
}
// ---- DELETE ----
public function test_delete_good(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo/branches/old' => Http::response('', 204),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
// Should not throw
$client->delete('/repos/owner/repo/branches/old');
$this->assertTrue(true);
}
public function test_delete_bad_not_found(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo/branches/gone' => Http::response('Not Found', 404),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('failed [404]');
$client->delete('/repos/owner/repo/branches/gone');
}
// ---- getRaw ----
public function test_getRaw_good(): void
{
Http::fake([
'forge.test/api/v1/repos/owner/repo/pulls/1.diff' => Http::response(
"diff --git a/file.txt b/file.txt\n",
200,
['Content-Type' => 'text/plain'],
),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$diff = $client->getRaw('/repos/owner/repo/pulls/1.diff');
$this->assertStringContainsString('diff --git', $diff);
}
// ---- Pagination ----
public function test_paginate_good(): void
{
Http::fake([
'forge.test/api/v1/orgs/myorg/repos?page=1&limit=2' => Http::response(
[['id' => 1], ['id' => 2]],
200,
['x-total-count' => '3'],
),
'forge.test/api/v1/orgs/myorg/repos?page=2&limit=2' => Http::response(
[['id' => 3]],
200,
['x-total-count' => '3'],
),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$repos = $client->paginate('/orgs/myorg/repos', [], 2);
$this->assertCount(3, $repos);
$this->assertSame(1, $repos[0]['id']);
$this->assertSame(3, $repos[2]['id']);
}
public function test_paginate_good_empty(): void
{
Http::fake([
'forge.test/api/v1/orgs/empty/repos?page=1&limit=50' => Http::response([], 200),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$repos = $client->paginate('/orgs/empty/repos');
$this->assertSame([], $repos);
}
// ---- Auth header ----
public function test_auth_header_sent(): void
{
Http::fake([
'forge.test/api/v1/user' => Http::response(['login' => 'bot'], 200),
]);
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
$client->get('/user');
Http::assertSent(function ($request) {
return $request->hasHeader('Authorization', 'token ' . self::TOKEN);
});
}
}

View file

@ -1,256 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\Forgejo;
use App\Services\Forgejo\ForgejoService;
use Illuminate\Support\Facades\Http;
use Orchestra\Testbench\TestCase;
use RuntimeException;
class ForgejoServiceTest extends TestCase
{
private const INSTANCES = [
'forge' => ['url' => 'https://forge.test', 'token' => 'tok-forge'],
'dev' => ['url' => 'https://dev.test', 'token' => 'tok-dev'],
];
private function service(): ForgejoService
{
return new ForgejoService(
instances: self::INSTANCES,
defaultInstance: 'forge',
timeout: 5,
retryTimes: 0,
retrySleep: 0,
);
}
// ---- Instance management ----
public function test_instances_good(): void
{
$svc = $this->service();
$this->assertSame(['forge', 'dev'], $svc->instances());
}
public function test_instances_skips_empty_token(): void
{
$svc = new ForgejoService(
instances: [
'forge' => ['url' => 'https://forge.test', 'token' => 'tok'],
'qa' => ['url' => 'https://qa.test', 'token' => ''],
],
);
$this->assertSame(['forge'], $svc->instances());
}
public function test_client_bad_unknown_instance(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("instance 'nope' is not configured");
$this->service()->client('nope');
}
// ---- Issues ----
public function test_createIssue_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/issues' => Http::response([
'number' => 99,
'title' => 'New bug',
], 201),
]);
$result = $this->service()->createIssue('org', 'repo', 'New bug', 'Description');
$this->assertSame(99, $result['number']);
Http::assertSent(fn ($r) => $r['title'] === 'New bug' && $r['body'] === 'Description');
}
public function test_createIssue_good_with_labels_and_assignee(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/issues' => Http::response(['number' => 1], 201),
]);
$this->service()->createIssue('org', 'repo', 'Task', assignee: 'alice', labels: [1, 2]);
Http::assertSent(fn ($r) => $r['assignees'] === ['alice'] && $r['labels'] === [1, 2]);
}
public function test_closeIssue_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/issues/5' => Http::response(['state' => 'closed'], 200),
]);
$result = $this->service()->closeIssue('org', 'repo', 5);
$this->assertSame('closed', $result['state']);
}
public function test_addComment_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/issues/5/comments' => Http::response(['id' => 100], 201),
]);
$result = $this->service()->addComment('org', 'repo', 5, 'LGTM');
$this->assertSame(100, $result['id']);
}
public function test_listIssues_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/issues*' => Http::response([
['number' => 1],
['number' => 2],
], 200),
]);
$issues = $this->service()->listIssues('org', 'repo');
$this->assertCount(2, $issues);
}
// ---- Pull Requests ----
public function test_createPR_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/pulls' => Http::response([
'number' => 10,
'title' => 'Feature X',
], 201),
]);
$result = $this->service()->createPR('org', 'repo', 'feat/x', 'main', 'Feature X');
$this->assertSame(10, $result['number']);
}
public function test_mergePR_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/pulls/10/merge' => Http::response([], 200),
]);
// Should not throw
$this->service()->mergePR('org', 'repo', 10, 'squash');
$this->assertTrue(true);
}
public function test_getPRDiff_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/pulls/10.diff' => Http::response(
"diff --git a/f.go b/f.go\n+new line\n",
200,
),
]);
$diff = $this->service()->getPRDiff('org', 'repo', 10);
$this->assertStringContainsString('diff --git', $diff);
}
// ---- Repositories ----
public function test_getRepo_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/core' => Http::response(['full_name' => 'org/core'], 200),
]);
$result = $this->service()->getRepo('org', 'core');
$this->assertSame('org/core', $result['full_name']);
}
public function test_createBranch_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/branches' => Http::response(['name' => 'feat/y'], 201),
]);
$result = $this->service()->createBranch('org', 'repo', 'feat/y', 'main');
$this->assertSame('feat/y', $result['name']);
Http::assertSent(fn ($r) =>
$r['new_branch_name'] === 'feat/y' && $r['old_branch_name'] === 'main'
);
}
public function test_deleteBranch_good(): void
{
Http::fake([
'forge.test/api/v1/repos/org/repo/branches/old' => Http::response('', 204),
]);
$this->service()->deleteBranch('org', 'repo', 'old');
$this->assertTrue(true);
}
// ---- User / Token Management ----
public function test_createUser_good(): void
{
Http::fake([
'forge.test/api/v1/admin/users' => Http::response(['login' => 'bot'], 201),
]);
$result = $this->service()->createUser('bot', 'bot@test.io', 's3cret');
$this->assertSame('bot', $result['login']);
Http::assertSent(fn ($r) =>
$r['username'] === 'bot'
&& $r['must_change_password'] === false
);
}
public function test_createToken_good(): void
{
Http::fake([
'forge.test/api/v1/users/bot/tokens' => Http::response(['sha1' => 'abc123'], 201),
]);
$result = $this->service()->createToken('bot', 'ci-token', ['repo', 'user']);
$this->assertSame('abc123', $result['sha1']);
}
public function test_revokeToken_good(): void
{
Http::fake([
'forge.test/api/v1/users/bot/tokens/42' => Http::response('', 204),
]);
$this->service()->revokeToken('bot', 42);
$this->assertTrue(true);
}
// ---- Multi-instance routing ----
public function test_explicit_instance_routing(): void
{
Http::fake([
'dev.test/api/v1/repos/org/repo' => Http::response(['full_name' => 'org/repo'], 200),
]);
$result = $this->service()->getRepo('org', 'repo', instance: 'dev');
$this->assertSame('org/repo', $result['full_name']);
Http::assertSent(fn ($r) => str_contains($r->url(), 'dev.test'));
}
}

View file

@ -1,102 +0,0 @@
// Package main provides the Core App — a native desktop application
// embedding Laravel via FrankenPHP inside a Wails v3 window.
//
// A single Go binary that boots the PHP runtime, extracts the embedded
// Laravel application, and serves it through FrankenPHP's ServeHTTP into
// a native webview via Wails v3's AssetOptions.Handler.
package main
import (
"context"
"log"
"runtime"
"forge.lthn.ai/core/go/cmd/core-app/icons"
"github.com/wailsapp/wails/v3/pkg/application"
)
func main() {
// Set up PHP handler (extracts Laravel, prepares env, inits FrankenPHP).
handler, env, cleanup, err := NewPHPHandler()
if err != nil {
log.Fatalf("Failed to initialise PHP handler: %v", err)
}
defer cleanup()
// Create the app service and native bridge.
appService := NewAppService(env)
bridge, err := NewNativeBridge(appService)
if err != nil {
log.Fatalf("Failed to start native bridge: %v", err)
}
defer bridge.Shutdown(context.Background())
// Inject the bridge URL into the Laravel .env so PHP can call Go.
if err := appendEnv(handler.laravelRoot, "NATIVE_BRIDGE_URL", bridge.URL()); err != nil {
log.Printf("Warning: couldn't inject bridge URL into .env: %v", err)
}
app := application.New(application.Options{
Name: "Core App",
Description: "Host UK Native App — Laravel powered by FrankenPHP",
Services: []application.Service{
application.NewService(appService),
},
Assets: application.AssetOptions{
Handler: handler,
},
Mac: application.MacOptions{
ActivationPolicy: application.ActivationPolicyAccessory,
},
})
appService.app = app
setupSystemTray(app)
// Main application window
app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "main",
Title: "Core App",
Width: 1200,
Height: 800,
URL: "/",
BackgroundColour: application.NewRGB(13, 17, 23),
})
log.Println("Starting Core App...")
if err := app.Run(); err != nil {
log.Fatal(err)
}
}
// setupSystemTray configures the system tray icon and menu.
func setupSystemTray(app *application.App) {
systray := app.SystemTray.New()
systray.SetTooltip("Core App")
if runtime.GOOS == "darwin" {
systray.SetTemplateIcon(icons.TrayTemplate)
} else {
systray.SetDarkModeIcon(icons.TrayDark)
systray.SetIcon(icons.TrayLight)
}
trayMenu := app.Menu.New()
trayMenu.Add("Open Core App").OnClick(func(ctx *application.Context) {
if w, ok := app.Window.Get("main"); ok {
w.Show()
w.Focus()
}
})
trayMenu.AddSeparator()
trayMenu.Add("Quit").OnClick(func(ctx *application.Context) {
app.Quit()
})
systray.SetMenu(trayMenu)
}

View file

@ -1,96 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
)
// NativeBridge provides a localhost HTTP API that PHP code can call
// to access native desktop capabilities (file dialogs, notifications, etc.).
//
// Livewire renders server-side in PHP, so it can't call Wails bindings
// (window.go.*) directly. Instead, PHP makes HTTP requests to this bridge.
// The bridge port is injected into Laravel's .env as NATIVE_BRIDGE_URL.
type NativeBridge struct {
server *http.Server
port int
app *AppService
}
// NewNativeBridge creates and starts the bridge on a random available port.
func NewNativeBridge(appService *AppService) (*NativeBridge, error) {
mux := http.NewServeMux()
bridge := &NativeBridge{app: appService}
// Register bridge endpoints
mux.HandleFunc("POST /bridge/version", bridge.handleVersion)
mux.HandleFunc("POST /bridge/data-dir", bridge.handleDataDir)
mux.HandleFunc("POST /bridge/show-window", bridge.handleShowWindow)
mux.HandleFunc("GET /bridge/health", bridge.handleHealth)
// Listen on a random available port (localhost only)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("listen: %w", err)
}
bridge.port = listener.Addr().(*net.TCPAddr).Port
bridge.server = &http.Server{Handler: mux}
go func() {
if err := bridge.server.Serve(listener); err != nil && err != http.ErrServerClosed {
log.Printf("Native bridge error: %v", err)
}
}()
log.Printf("Native bridge listening on http://127.0.0.1:%d", bridge.port)
return bridge, nil
}
// Port returns the port the bridge is listening on.
func (b *NativeBridge) Port() int {
return b.port
}
// URL returns the full base URL of the bridge.
func (b *NativeBridge) URL() string {
return fmt.Sprintf("http://127.0.0.1:%d", b.port)
}
// Shutdown gracefully stops the bridge server.
func (b *NativeBridge) Shutdown(ctx context.Context) error {
return b.server.Shutdown(ctx)
}
func (b *NativeBridge) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "ok"})
}
func (b *NativeBridge) handleVersion(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"version": b.app.GetVersion()})
}
func (b *NativeBridge) handleDataDir(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"path": b.app.GetDataDir()})
}
func (b *NativeBridge) handleShowWindow(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
b.app.ShowWindow(req.Name)
writeJSON(w, map[string]string{"status": "ok"})
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}

View file

@ -1,22 +0,0 @@
package dev
import (
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
// addAPICommands adds the 'api' command and its subcommands to the given parent command.
func addAPICommands(parent *cli.Command) {
// Create the 'api' command
apiCmd := &cli.Command{
Use: "api",
Short: i18n.T("cmd.dev.api.short"),
}
parent.AddCommand(apiCmd)
// Add the 'sync' command to 'api'
addSyncCommand(apiCmd)
// TODO: Add the 'test-gen' command to 'api'
// addTestGenCommand(apiCmd)
}

View file

@ -1,304 +0,0 @@
// cmd_apply.go implements safe command/script execution across repos for AI agents.
//
// Usage:
// core dev apply --command="sed -i 's/old/new/g' README.md"
// core dev apply --script="./scripts/update-version.sh"
// core dev apply --command="..." --commit --message="chore: update"
package dev
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"forge.lthn.ai/core/cli/pkg/cli"
core "forge.lthn.ai/core/go/pkg/framework/core"
"forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos"
)
// Apply command flags
var (
applyCommand string
applyScript string
applyRepos string
applyCommit bool
applyMessage string
applyCoAuthor string
applyDryRun bool
applyPush bool
applyContinue bool // Continue on error
applyYes bool // Skip confirmation prompt
)
// AddApplyCommand adds the 'apply' command to dev.
func AddApplyCommand(parent *cli.Command) {
applyCmd := &cli.Command{
Use: "apply",
Short: i18n.T("cmd.dev.apply.short"),
Long: i18n.T("cmd.dev.apply.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runApply()
},
}
applyCmd.Flags().StringVar(&applyCommand, "command", "", i18n.T("cmd.dev.apply.flag.command"))
applyCmd.Flags().StringVar(&applyScript, "script", "", i18n.T("cmd.dev.apply.flag.script"))
applyCmd.Flags().StringVar(&applyRepos, "repos", "", i18n.T("cmd.dev.apply.flag.repos"))
applyCmd.Flags().BoolVar(&applyCommit, "commit", false, i18n.T("cmd.dev.apply.flag.commit"))
applyCmd.Flags().StringVarP(&applyMessage, "message", "m", "", i18n.T("cmd.dev.apply.flag.message"))
applyCmd.Flags().StringVar(&applyCoAuthor, "co-author", "", i18n.T("cmd.dev.apply.flag.co_author"))
applyCmd.Flags().BoolVar(&applyDryRun, "dry-run", false, i18n.T("cmd.dev.apply.flag.dry_run"))
applyCmd.Flags().BoolVar(&applyPush, "push", false, i18n.T("cmd.dev.apply.flag.push"))
applyCmd.Flags().BoolVar(&applyContinue, "continue", false, i18n.T("cmd.dev.apply.flag.continue"))
applyCmd.Flags().BoolVarP(&applyYes, "yes", "y", false, i18n.T("cmd.dev.apply.flag.yes"))
parent.AddCommand(applyCmd)
}
func runApply() error {
ctx := context.Background()
// Validate inputs
if applyCommand == "" && applyScript == "" {
return core.E("dev.apply", i18n.T("cmd.dev.apply.error.no_command"), nil)
}
if applyCommand != "" && applyScript != "" {
return core.E("dev.apply", i18n.T("cmd.dev.apply.error.both_command_script"), nil)
}
if applyCommit && applyMessage == "" {
return core.E("dev.apply", i18n.T("cmd.dev.apply.error.commit_needs_message"), nil)
}
// Validate script exists
if applyScript != "" {
if !io.Local.IsFile(applyScript) {
return core.E("dev.apply", "script not found: "+applyScript, nil) // Error mismatch? IsFile returns bool
}
}
// Get target repos
targetRepos, err := getApplyTargetRepos()
if err != nil {
return err
}
if len(targetRepos) == 0 {
return core.E("dev.apply", i18n.T("cmd.dev.apply.error.no_repos"), nil)
}
// Show plan
action := applyCommand
if applyScript != "" {
action = applyScript
}
cli.Print("%s: %s\n", dimStyle.Render(i18n.T("cmd.dev.apply.action")), action)
cli.Print("%s: %d repos\n", dimStyle.Render(i18n.T("cmd.dev.apply.targets")), len(targetRepos))
if applyDryRun {
cli.Print("%s\n", warningStyle.Render(i18n.T("cmd.dev.apply.dry_run_mode")))
}
cli.Blank()
// Require confirmation unless --yes or --dry-run
if !applyYes && !applyDryRun {
cli.Print("%s\n", warningStyle.Render(i18n.T("cmd.dev.apply.warning")))
cli.Blank()
if !cli.Confirm(i18n.T("cmd.dev.apply.confirm"), cli.Required()) {
cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.dev.apply.cancelled")))
return nil
}
cli.Blank()
}
var succeeded, skipped, failed int
for _, repo := range targetRepos {
repoName := filepath.Base(repo.Path)
if applyDryRun {
cli.Print(" %s %s\n", dimStyle.Render("[dry-run]"), repoName)
succeeded++
continue
}
// Step 1: Run command or script
var cmdErr error
if applyCommand != "" {
cmdErr = runCommandInRepo(ctx, repo.Path, applyCommand)
} else {
cmdErr = runScriptInRepo(ctx, repo.Path, applyScript)
}
if cmdErr != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), repoName, cmdErr)
failed++
if !applyContinue {
return cli.Err("%s", i18n.T("cmd.dev.apply.error.command_failed"))
}
continue
}
// Step 2: Check if anything changed
statuses := git.Status(ctx, git.StatusOptions{
Paths: []string{repo.Path},
Names: map[string]string{repo.Path: repoName},
})
if len(statuses) == 0 || !statuses[0].IsDirty() {
cli.Print(" %s %s: %s\n", dimStyle.Render("-"), repoName, i18n.T("cmd.dev.apply.no_changes"))
skipped++
continue
}
// Step 3: Commit if requested
if applyCommit {
commitMsg := applyMessage
if applyCoAuthor != "" {
commitMsg += "\n\nCo-Authored-By: " + applyCoAuthor
}
// Stage all changes
if _, err := gitCommandQuiet(ctx, repo.Path, "add", "-A"); err != nil {
cli.Print(" %s %s: stage failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
if !applyContinue {
return err
}
continue
}
// Commit
if _, err := gitCommandQuiet(ctx, repo.Path, "commit", "-m", commitMsg); err != nil {
cli.Print(" %s %s: commit failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
if !applyContinue {
return err
}
continue
}
// Step 4: Push if requested
if applyPush {
if err := safePush(ctx, repo.Path); err != nil {
cli.Print(" %s %s: push failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
if !applyContinue {
return err
}
continue
}
}
}
cli.Print(" %s %s\n", successStyle.Render("v"), repoName)
succeeded++
}
// Summary
cli.Blank()
cli.Print("%s: ", i18n.T("cmd.dev.apply.summary"))
if succeeded > 0 {
cli.Print("%s", successStyle.Render(i18n.T("common.count.succeeded", map[string]interface{}{"Count": succeeded})))
}
if skipped > 0 {
if succeeded > 0 {
cli.Print(", ")
}
cli.Print("%s", dimStyle.Render(i18n.T("common.count.skipped", map[string]interface{}{"Count": skipped})))
}
if failed > 0 {
if succeeded > 0 || skipped > 0 {
cli.Print(", ")
}
cli.Print("%s", errorStyle.Render(i18n.T("common.count.failed", map[string]interface{}{"Count": failed})))
}
cli.Blank()
return nil
}
// getApplyTargetRepos gets repos to apply command to
func getApplyTargetRepos() ([]*repos.Repo, error) {
// Load registry
registryPath, err := repos.FindRegistry(io.Local)
if err != nil {
return nil, core.E("dev.apply", "failed to find registry", err)
}
registry, err := repos.LoadRegistry(io.Local, registryPath)
if err != nil {
return nil, core.E("dev.apply", "failed to load registry", err)
}
// If --repos specified, filter to those
if applyRepos != "" {
repoNames := strings.Split(applyRepos, ",")
nameSet := make(map[string]bool)
for _, name := range repoNames {
nameSet[strings.TrimSpace(name)] = true
}
var matched []*repos.Repo
for _, repo := range registry.Repos {
if nameSet[repo.Name] {
matched = append(matched, repo)
}
}
return matched, nil
}
// Return all repos as slice
var all []*repos.Repo
for _, repo := range registry.Repos {
all = append(all, repo)
}
return all, nil
}
// runCommandInRepo runs a shell command in a repo directory
func runCommandInRepo(ctx context.Context, repoPath, command string) error {
// Use shell to execute command
var cmd *exec.Cmd
if isWindows() {
cmd = exec.CommandContext(ctx, "cmd", "/C", command)
} else {
cmd = exec.CommandContext(ctx, "sh", "-c", command)
}
cmd.Dir = repoPath
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// runScriptInRepo runs a script in a repo directory
func runScriptInRepo(ctx context.Context, repoPath, scriptPath string) error {
// Get absolute path to script
absScript, err := filepath.Abs(scriptPath)
if err != nil {
return err
}
var cmd *exec.Cmd
if isWindows() {
cmd = exec.CommandContext(ctx, "cmd", "/C", absScript)
} else {
// Execute script directly to honor shebang
cmd = exec.CommandContext(ctx, absScript)
}
cmd.Dir = repoPath
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// isWindows returns true if running on Windows
func isWindows() bool {
return os.PathSeparator == '\\'
}

View file

@ -1,86 +0,0 @@
package dev
import (
"context"
"forge.lthn.ai/core/go-agentic"
"forge.lthn.ai/core/go/pkg/framework"
"forge.lthn.ai/core/go-scm/git"
)
// WorkBundle contains the Core instance for dev work operations.
type WorkBundle struct {
Core *framework.Core
}
// WorkBundleOptions configures the work bundle.
type WorkBundleOptions struct {
RegistryPath string
AllowEdit bool // Allow agentic to use Write/Edit tools
}
// NewWorkBundle creates a bundle for dev work operations.
// Includes: dev (orchestration), git, agentic services.
func NewWorkBundle(opts WorkBundleOptions) (*WorkBundle, error) {
c, err := framework.New(
framework.WithService(NewService(ServiceOptions{
RegistryPath: opts.RegistryPath,
})),
framework.WithService(git.NewService(git.ServiceOptions{})),
framework.WithService(agentic.NewService(agentic.ServiceOptions{
AllowEdit: opts.AllowEdit,
})),
framework.WithServiceLock(),
)
if err != nil {
return nil, err
}
return &WorkBundle{Core: c}, nil
}
// Start initialises the bundle services.
func (b *WorkBundle) Start(ctx context.Context) error {
return b.Core.ServiceStartup(ctx, nil)
}
// Stop shuts down the bundle services.
func (b *WorkBundle) Stop(ctx context.Context) error {
return b.Core.ServiceShutdown(ctx)
}
// StatusBundle contains the Core instance for status-only operations.
type StatusBundle struct {
Core *framework.Core
}
// StatusBundleOptions configures the status bundle.
type StatusBundleOptions struct {
RegistryPath string
}
// NewStatusBundle creates a bundle for status-only operations.
// Includes: dev (orchestration), git services. No agentic - commits not available.
func NewStatusBundle(opts StatusBundleOptions) (*StatusBundle, error) {
c, err := framework.New(
framework.WithService(NewService(ServiceOptions(opts))),
framework.WithService(git.NewService(git.ServiceOptions{})),
// No agentic service - TaskCommit will be unhandled
framework.WithServiceLock(),
)
if err != nil {
return nil, err
}
return &StatusBundle{Core: c}, nil
}
// Start initialises the bundle services.
func (b *StatusBundle) Start(ctx context.Context) error {
return b.Core.ServiceStartup(ctx, nil)
}
// Stop shuts down the bundle services.
func (b *StatusBundle) Stop(ctx context.Context) error {
return b.Core.ServiceShutdown(ctx)
}

View file

@ -1,261 +0,0 @@
package dev
import (
"encoding/json"
"errors"
"os"
"os/exec"
"strings"
"time"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos"
)
// CI-specific styles (aliases to shared)
var (
ciSuccessStyle = cli.SuccessStyle
ciFailureStyle = cli.ErrorStyle
ciPendingStyle = cli.WarningStyle
ciSkippedStyle = cli.DimStyle
)
// WorkflowRun represents a GitHub Actions workflow run
type WorkflowRun struct {
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
HeadBranch string `json:"headBranch"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
URL string `json:"url"`
// Added by us
RepoName string `json:"-"`
}
// CI command flags
var (
ciRegistryPath string
ciBranch string
ciFailedOnly bool
)
// addCICommand adds the 'ci' command to the given parent command.
func addCICommand(parent *cli.Command) {
ciCmd := &cli.Command{
Use: "ci",
Short: i18n.T("cmd.dev.ci.short"),
Long: i18n.T("cmd.dev.ci.long"),
RunE: func(cmd *cli.Command, args []string) error {
branch := ciBranch
if branch == "" {
branch = "main"
}
return runCI(ciRegistryPath, branch, ciFailedOnly)
},
}
ciCmd.Flags().StringVar(&ciRegistryPath, "registry", "", i18n.T("common.flag.registry"))
ciCmd.Flags().StringVarP(&ciBranch, "branch", "b", "main", i18n.T("cmd.dev.ci.flag.branch"))
ciCmd.Flags().BoolVar(&ciFailedOnly, "failed", false, i18n.T("cmd.dev.ci.flag.failed"))
parent.AddCommand(ciCmd)
}
func runCI(registryPath string, branch string, failedOnly bool) error {
// Check gh is available
if _, err := exec.LookPath("gh"); err != nil {
return errors.New(i18n.T("error.gh_not_found"))
}
// Find or use provided registry
var reg *repos.Registry
var err error
if registryPath != "" {
reg, err = repos.LoadRegistry(io.Local, registryPath)
if err != nil {
return cli.Wrap(err, "failed to load registry")
}
} else {
registryPath, err = repos.FindRegistry(io.Local)
if err == nil {
reg, err = repos.LoadRegistry(io.Local, registryPath)
if err != nil {
return cli.Wrap(err, "failed to load registry")
}
} else {
cwd, _ := os.Getwd()
reg, err = repos.ScanDirectory(io.Local, cwd)
if err != nil {
return cli.Wrap(err, "failed to scan directory")
}
}
}
// Fetch CI status sequentially
var allRuns []WorkflowRun
var fetchErrors []error
var noCI []string
repoList := reg.List()
for i, repo := range repoList {
repoFullName := cli.Sprintf("%s/%s", reg.Org, repo.Name)
cli.Print("\033[2K\r%s %d/%d %s", dimStyle.Render(i18n.T("i18n.progress.check")), i+1, len(repoList), repo.Name)
runs, err := fetchWorkflowRuns(repoFullName, repo.Name, branch)
if err != nil {
if strings.Contains(err.Error(), "no workflows") {
noCI = append(noCI, repo.Name)
} else {
fetchErrors = append(fetchErrors, cli.Wrap(err, repo.Name))
}
continue
}
if len(runs) > 0 {
// Just get the latest run
allRuns = append(allRuns, runs[0])
} else {
noCI = append(noCI, repo.Name)
}
}
cli.Print("\033[2K\r") // Clear progress line
// Count by status
var success, failed, pending, other int
for _, run := range allRuns {
switch run.Conclusion {
case "success":
success++
case "failure":
failed++
case "":
if run.Status == "in_progress" || run.Status == "queued" {
pending++
} else {
other++
}
default:
other++
}
}
// Print summary
cli.Blank()
cli.Print("%s", i18n.T("cmd.dev.ci.repos_checked", map[string]interface{}{"Count": len(repoList)}))
if success > 0 {
cli.Print(" * %s", ciSuccessStyle.Render(i18n.T("cmd.dev.ci.passing", map[string]interface{}{"Count": success})))
}
if failed > 0 {
cli.Print(" * %s", ciFailureStyle.Render(i18n.T("cmd.dev.ci.failing", map[string]interface{}{"Count": failed})))
}
if pending > 0 {
cli.Print(" * %s", ciPendingStyle.Render(i18n.T("common.count.pending", map[string]interface{}{"Count": pending})))
}
if len(noCI) > 0 {
cli.Print(" * %s", ciSkippedStyle.Render(i18n.T("cmd.dev.ci.no_ci", map[string]interface{}{"Count": len(noCI)})))
}
cli.Blank()
cli.Blank()
// Filter if needed
displayRuns := allRuns
if failedOnly {
displayRuns = nil
for _, run := range allRuns {
if run.Conclusion == "failure" {
displayRuns = append(displayRuns, run)
}
}
}
// Print details
for _, run := range displayRuns {
printWorkflowRun(run)
}
// Print errors
if len(fetchErrors) > 0 {
cli.Blank()
for _, err := range fetchErrors {
cli.Print("%s %s\n", errorStyle.Render(i18n.Label("error")), err)
}
}
return nil
}
func fetchWorkflowRuns(repoFullName, repoName string, branch string) ([]WorkflowRun, error) {
args := []string{
"run", "list",
"--repo", repoFullName,
"--branch", branch,
"--limit", "1",
"--json", "name,status,conclusion,headBranch,createdAt,updatedAt,url",
}
cmd := exec.Command("gh", args...)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := string(exitErr.Stderr)
return nil, cli.Err("%s", strings.TrimSpace(stderr))
}
return nil, err
}
var runs []WorkflowRun
if err := json.Unmarshal(output, &runs); err != nil {
return nil, err
}
// Tag with repo name
for i := range runs {
runs[i].RepoName = repoName
}
return runs, nil
}
func printWorkflowRun(run WorkflowRun) {
// Status icon
var status string
switch run.Conclusion {
case "success":
status = ciSuccessStyle.Render("v")
case "failure":
status = ciFailureStyle.Render("x")
case "":
switch run.Status {
case "in_progress":
status = ciPendingStyle.Render("*")
case "queued":
status = ciPendingStyle.Render("o")
default:
status = ciSkippedStyle.Render("-")
}
case "skipped":
status = ciSkippedStyle.Render("-")
case "cancelled":
status = ciSkippedStyle.Render("o")
default:
status = ciSkippedStyle.Render("?")
}
// Workflow name (truncated)
workflowName := cli.Truncate(run.Name, 20)
// Age
age := cli.FormatAge(run.UpdatedAt)
cli.Print(" %s %-18s %-22s %s\n",
status,
repoNameStyle.Render(run.RepoName),
dimStyle.Render(workflowName),
issueAgeStyle.Render(age),
)
}

View file

@ -1,201 +0,0 @@
package dev
import (
"context"
"os"
"path/filepath"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go/pkg/i18n"
coreio "forge.lthn.ai/core/go/pkg/io"
)
// Commit command flags
var (
commitRegistryPath string
commitAll bool
)
// AddCommitCommand adds the 'commit' command to the given parent command.
func AddCommitCommand(parent *cli.Command) {
commitCmd := &cli.Command{
Use: "commit",
Short: i18n.T("cmd.dev.commit.short"),
Long: i18n.T("cmd.dev.commit.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runCommit(commitRegistryPath, commitAll)
},
}
commitCmd.Flags().StringVar(&commitRegistryPath, "registry", "", i18n.T("common.flag.registry"))
commitCmd.Flags().BoolVar(&commitAll, "all", false, i18n.T("cmd.dev.commit.flag.all"))
parent.AddCommand(commitCmd)
}
func runCommit(registryPath string, all bool) error {
ctx := context.Background()
cwd, _ := os.Getwd()
// Check if current directory is a git repo (single-repo mode)
if registryPath == "" && isGitRepo(cwd) {
return runCommitSingleRepo(ctx, cwd, all)
}
// Multi-repo mode: find or use provided registry
reg, regDir, err := loadRegistryWithConfig(registryPath)
if err != nil {
return err
}
registryPath = regDir // Use resolved registry directory for relative paths
// Build paths and names for git operations
var paths []string
names := make(map[string]string)
for _, repo := range reg.List() {
if repo.IsGitRepo() {
paths = append(paths, repo.Path)
names[repo.Path] = repo.Name
}
}
if len(paths) == 0 {
cli.Text(i18n.T("cmd.dev.no_git_repos"))
return nil
}
// Get status for all repos
statuses := git.Status(ctx, git.StatusOptions{
Paths: paths,
Names: names,
})
// Find dirty repos
var dirtyRepos []git.RepoStatus
for _, s := range statuses {
if s.Error == nil && s.IsDirty() {
dirtyRepos = append(dirtyRepos, s)
}
}
if len(dirtyRepos) == 0 {
cli.Text(i18n.T("cmd.dev.no_changes"))
return nil
}
// Show dirty repos
cli.Print("\n%s\n\n", i18n.T("cmd.dev.repos_with_changes", map[string]interface{}{"Count": len(dirtyRepos)}))
for _, s := range dirtyRepos {
cli.Print(" %s: ", repoNameStyle.Render(s.Name))
if s.Modified > 0 {
cli.Print("%s ", dirtyStyle.Render(i18n.T("cmd.dev.modified", map[string]interface{}{"Count": s.Modified})))
}
if s.Untracked > 0 {
cli.Print("%s ", dirtyStyle.Render(i18n.T("cmd.dev.untracked", map[string]interface{}{"Count": s.Untracked})))
}
if s.Staged > 0 {
cli.Print("%s ", aheadStyle.Render(i18n.T("cmd.dev.staged", map[string]interface{}{"Count": s.Staged})))
}
cli.Blank()
}
// Confirm unless --all
if !all {
cli.Blank()
if !cli.Confirm(i18n.T("cmd.dev.confirm_claude_commit")) {
cli.Text(i18n.T("cli.aborted"))
return nil
}
}
cli.Blank()
// Commit each dirty repo
var succeeded, failed int
for _, s := range dirtyRepos {
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.dev.committing")), s.Name)
if err := claudeCommit(ctx, s.Path, s.Name, registryPath); err != nil {
cli.Print(" %s %s\n", errorStyle.Render("x"), err)
failed++
} else {
cli.Print(" %s %s\n", successStyle.Render("v"), i18n.T("cmd.dev.committed"))
succeeded++
}
cli.Blank()
}
// Summary
cli.Print("%s", successStyle.Render(i18n.T("cmd.dev.done_succeeded", map[string]interface{}{"Count": succeeded})))
if failed > 0 {
cli.Print(", %s", errorStyle.Render(i18n.T("common.count.failed", map[string]interface{}{"Count": failed})))
}
cli.Blank()
return nil
}
// isGitRepo checks if a directory is a git repository.
func isGitRepo(path string) bool {
gitDir := path + "/.git"
_, err := coreio.Local.List(gitDir)
return err == nil
}
// runCommitSingleRepo handles commit for a single repo (current directory).
func runCommitSingleRepo(ctx context.Context, repoPath string, all bool) error {
repoName := filepath.Base(repoPath)
// Get status
statuses := git.Status(ctx, git.StatusOptions{
Paths: []string{repoPath},
Names: map[string]string{repoPath: repoName},
})
if len(statuses) == 0 || statuses[0].Error != nil {
if len(statuses) > 0 && statuses[0].Error != nil {
return statuses[0].Error
}
return cli.Err("failed to get repo status")
}
s := statuses[0]
if !s.IsDirty() {
cli.Text(i18n.T("cmd.dev.no_changes"))
return nil
}
// Show status
cli.Print("%s: ", repoNameStyle.Render(s.Name))
if s.Modified > 0 {
cli.Print("%s ", dirtyStyle.Render(i18n.T("cmd.dev.modified", map[string]interface{}{"Count": s.Modified})))
}
if s.Untracked > 0 {
cli.Print("%s ", dirtyStyle.Render(i18n.T("cmd.dev.untracked", map[string]interface{}{"Count": s.Untracked})))
}
if s.Staged > 0 {
cli.Print("%s ", aheadStyle.Render(i18n.T("cmd.dev.staged", map[string]interface{}{"Count": s.Staged})))
}
cli.Blank()
// Confirm unless --all
if !all {
cli.Blank()
if !cli.Confirm(i18n.T("cmd.dev.confirm_claude_commit")) {
cli.Text(i18n.T("cli.aborted"))
return nil
}
}
cli.Blank()
// Commit
if err := claudeCommit(ctx, repoPath, repoName, ""); err != nil {
cli.Print(" %s %s\n", errorStyle.Render("x"), err)
return err
}
cli.Print(" %s %s\n", successStyle.Render("v"), i18n.T("cmd.dev.committed"))
return nil
}

View file

@ -1,96 +0,0 @@
// Package dev provides multi-repo development workflow commands.
//
// Git Operations:
// - work: Combined status, commit, and push workflow
// - health: Quick health check across all repos
// - commit: Claude-assisted commit message generation
// - push: Push repos with unpushed commits
// - pull: Pull repos that are behind remote
//
// GitHub Integration (requires gh CLI):
// - issues: List open issues across repos
// - reviews: List PRs needing review
// - ci: Check GitHub Actions CI status
// - impact: Analyse dependency impact of changes
//
// CI/Workflow Management:
// - workflow list: Show table of repos vs workflows
// - workflow sync: Copy workflow template to all repos
//
// API Tools:
// - api sync: Synchronize public service APIs
//
// Dev Environment (VM management):
// - install: Download dev environment image
// - boot: Start dev environment VM
// - stop: Stop dev environment VM
// - status: Check dev VM status
// - shell: Open shell in dev VM
// - serve: Mount project and start dev server
// - test: Run tests in dev environment
// - claude: Start sandboxed Claude session
// - update: Check for and apply updates
package dev
import (
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
func init() {
cli.RegisterCommands(AddDevCommands)
}
// Style aliases from shared package
var (
successStyle = cli.SuccessStyle
errorStyle = cli.ErrorStyle
warningStyle = cli.WarningStyle
dimStyle = cli.DimStyle
valueStyle = cli.ValueStyle
headerStyle = cli.HeaderStyle
repoNameStyle = cli.RepoStyle
)
// Table styles for status display (extends shared styles with cell padding)
var (
dirtyStyle = cli.NewStyle().Foreground(cli.ColourRed500)
aheadStyle = cli.NewStyle().Foreground(cli.ColourAmber500)
cleanStyle = cli.NewStyle().Foreground(cli.ColourGreen500)
)
// AddDevCommands registers the 'dev' command and all subcommands.
func AddDevCommands(root *cli.Command) {
devCmd := &cli.Command{
Use: "dev",
Short: i18n.T("cmd.dev.short"),
Long: i18n.T("cmd.dev.long"),
}
root.AddCommand(devCmd)
// Git operations (also available under 'core git')
AddWorkCommand(devCmd)
AddHealthCommand(devCmd)
AddCommitCommand(devCmd)
AddPushCommand(devCmd)
AddPullCommand(devCmd)
// Safe git operations for AI agents (also available under 'core git')
AddFileSyncCommand(devCmd)
AddApplyCommand(devCmd)
// GitHub integration
addIssuesCommand(devCmd)
addReviewsCommand(devCmd)
addCICommand(devCmd)
addImpactCommand(devCmd)
// CI/Workflow management
addWorkflowCommands(devCmd)
// API tools
addAPICommands(devCmd)
// Dev environment
addVMCommands(devCmd)
}

View file

@ -1,340 +0,0 @@
// cmd_file_sync.go implements safe file synchronization across repos for AI agents.
//
// Usage:
// core dev sync workflow.yml --to="packages/core-*"
// core dev sync .github/workflows/ --to="packages/core-*" --message="feat: add CI"
// core dev sync config.yaml --to="packages/core-*" --dry-run
package dev
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go/pkg/i18n"
coreio "forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/log"
"forge.lthn.ai/core/go/pkg/repos"
)
// File sync command flags
var (
fileSyncTo string
fileSyncMessage string
fileSyncCoAuthor string
fileSyncDryRun bool
fileSyncPush bool
)
// AddFileSyncCommand adds the 'sync' command to dev for file syncing.
func AddFileSyncCommand(parent *cli.Command) {
syncCmd := &cli.Command{
Use: "sync <file-or-dir>",
Short: i18n.T("cmd.dev.file_sync.short"),
Long: i18n.T("cmd.dev.file_sync.long"),
Args: cli.MinimumNArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
return runFileSync(args[0])
},
}
syncCmd.Flags().StringVar(&fileSyncTo, "to", "", i18n.T("cmd.dev.file_sync.flag.to"))
syncCmd.Flags().StringVarP(&fileSyncMessage, "message", "m", "", i18n.T("cmd.dev.file_sync.flag.message"))
syncCmd.Flags().StringVar(&fileSyncCoAuthor, "co-author", "", i18n.T("cmd.dev.file_sync.flag.co_author"))
syncCmd.Flags().BoolVar(&fileSyncDryRun, "dry-run", false, i18n.T("cmd.dev.file_sync.flag.dry_run"))
syncCmd.Flags().BoolVar(&fileSyncPush, "push", false, i18n.T("cmd.dev.file_sync.flag.push"))
_ = syncCmd.MarkFlagRequired("to")
parent.AddCommand(syncCmd)
}
func runFileSync(source string) error {
ctx := context.Background()
// Security: Reject path traversal attempts
if strings.Contains(source, "..") {
return log.E("dev.sync", "path traversal not allowed", nil)
}
// Validate source exists
sourceInfo, err := os.Stat(source) // Keep os.Stat for local source check or use coreio? coreio.Local.IsFile is bool.
// If source is local file on disk (not in medium), we can use os.Stat.
// But concept is everything is via Medium?
// User is running CLI on host. `source` is relative to CWD.
// coreio.Local uses absolute path or relative to root (which is "/" by default).
// So coreio.Local works.
if !coreio.Local.IsFile(source) {
// Might be directory
// IsFile returns false for directory.
}
// Let's rely on os.Stat for initial source check to distinguish dir vs file easily if coreio doesn't expose Stat.
// coreio doesn't expose Stat.
// Check using standard os for source determination as we are outside strict sandbox for input args potentially?
// But we should use coreio where possible.
// coreio.Local.List worked for dirs.
// Let's stick to os.Stat for source properties finding as typically allowed for CLI args.
if err != nil {
return log.E("dev.sync", i18n.T("cmd.dev.file_sync.error.source_not_found", map[string]interface{}{"Path": source}), err)
}
// Find target repos
targetRepos, err := resolveTargetRepos(fileSyncTo)
if err != nil {
return err
}
if len(targetRepos) == 0 {
return cli.Err("%s", i18n.T("cmd.dev.file_sync.error.no_targets"))
}
// Show plan
cli.Print("%s: %s\n", dimStyle.Render(i18n.T("cmd.dev.file_sync.source")), source)
cli.Print("%s: %d repos\n", dimStyle.Render(i18n.T("cmd.dev.file_sync.targets")), len(targetRepos))
if fileSyncDryRun {
cli.Print("%s\n", warningStyle.Render(i18n.T("cmd.dev.file_sync.dry_run_mode")))
}
cli.Blank()
var succeeded, skipped, failed int
for _, repo := range targetRepos {
repoName := filepath.Base(repo.Path)
if fileSyncDryRun {
cli.Print(" %s %s\n", dimStyle.Render("[dry-run]"), repoName)
succeeded++
continue
}
// Step 1: Pull latest (safe sync)
if err := safePull(ctx, repo.Path); err != nil {
cli.Print(" %s %s: pull failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
continue
}
// Step 2: Copy file(s)
destPath := filepath.Join(repo.Path, source)
if sourceInfo.IsDir() {
if err := copyDir(source, destPath); err != nil {
cli.Print(" %s %s: copy failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
continue
}
} else {
// Ensure dir exists
if err := coreio.Local.EnsureDir(filepath.Dir(destPath)); err != nil {
cli.Print(" %s %s: copy failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
continue
}
if err := coreio.Copy(coreio.Local, source, coreio.Local, destPath); err != nil {
cli.Print(" %s %s: copy failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
continue
}
}
// Step 3: Check if anything changed
statuses := git.Status(ctx, git.StatusOptions{
Paths: []string{repo.Path},
Names: map[string]string{repo.Path: repoName},
})
if len(statuses) == 0 || !statuses[0].IsDirty() {
cli.Print(" %s %s: %s\n", dimStyle.Render("-"), repoName, i18n.T("cmd.dev.file_sync.no_changes"))
skipped++
continue
}
// Step 4: Commit if message provided
if fileSyncMessage != "" {
commitMsg := fileSyncMessage
if fileSyncCoAuthor != "" {
commitMsg += "\n\nCo-Authored-By: " + fileSyncCoAuthor
}
if err := gitAddCommit(ctx, repo.Path, source, commitMsg); err != nil {
cli.Print(" %s %s: commit failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
continue
}
// Step 5: Push if requested
if fileSyncPush {
if err := safePush(ctx, repo.Path); err != nil {
cli.Print(" %s %s: push failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
continue
}
}
}
cli.Print(" %s %s\n", successStyle.Render("v"), repoName)
succeeded++
}
// Summary
cli.Blank()
cli.Print("%s: ", i18n.T("cmd.dev.file_sync.summary"))
if succeeded > 0 {
cli.Print("%s", successStyle.Render(i18n.T("common.count.succeeded", map[string]interface{}{"Count": succeeded})))
}
if skipped > 0 {
if succeeded > 0 {
cli.Print(", ")
}
cli.Print("%s", dimStyle.Render(i18n.T("common.count.skipped", map[string]interface{}{"Count": skipped})))
}
if failed > 0 {
if succeeded > 0 || skipped > 0 {
cli.Print(", ")
}
cli.Print("%s", errorStyle.Render(i18n.T("common.count.failed", map[string]interface{}{"Count": failed})))
}
cli.Blank()
return nil
}
// resolveTargetRepos resolves the --to pattern to actual repos
func resolveTargetRepos(pattern string) ([]*repos.Repo, error) {
// Load registry
registryPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return nil, log.E("dev.sync", "failed to find registry", err)
}
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
if err != nil {
return nil, log.E("dev.sync", "failed to load registry", err)
}
// Match pattern against repo names
var matched []*repos.Repo
for _, repo := range registry.Repos {
if matchGlob(repo.Name, pattern) || matchGlob(repo.Path, pattern) {
matched = append(matched, repo)
}
}
return matched, nil
}
// matchGlob performs simple glob matching with * wildcards
func matchGlob(s, pattern string) bool {
// Handle exact match
if s == pattern {
return true
}
// Handle * at end
if strings.HasSuffix(pattern, "*") {
prefix := strings.TrimSuffix(pattern, "*")
return strings.HasPrefix(s, prefix)
}
// Handle * at start
if strings.HasPrefix(pattern, "*") {
suffix := strings.TrimPrefix(pattern, "*")
return strings.HasSuffix(s, suffix)
}
// Handle * in middle
if strings.Contains(pattern, "*") {
parts := strings.SplitN(pattern, "*", 2)
return strings.HasPrefix(s, parts[0]) && strings.HasSuffix(s, parts[1])
}
return false
}
// safePull pulls with rebase, handling errors gracefully
func safePull(ctx context.Context, path string) error {
// Check if we have upstream
_, err := gitCommandQuiet(ctx, path, "rev-parse", "--abbrev-ref", "@{u}")
if err != nil {
// No upstream set, skip pull
return nil
}
return git.Pull(ctx, path)
}
// safePush pushes with automatic pull-rebase on rejection
func safePush(ctx context.Context, path string) error {
err := git.Push(ctx, path)
if err == nil {
return nil
}
// If non-fast-forward, try pull and push again
if git.IsNonFastForward(err) {
if pullErr := git.Pull(ctx, path); pullErr != nil {
return pullErr
}
return git.Push(ctx, path)
}
return err
}
// gitAddCommit stages and commits a file/directory
func gitAddCommit(ctx context.Context, repoPath, filePath, message string) error {
// Stage the file(s)
if _, err := gitCommandQuiet(ctx, repoPath, "add", filePath); err != nil {
return err
}
// Commit
_, err := gitCommandQuiet(ctx, repoPath, "commit", "-m", message)
return err
}
// gitCommandQuiet runs a git command without output
func gitCommandQuiet(ctx context.Context, dir string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = dir
output, err := cmd.CombinedOutput()
if err != nil {
return "", cli.Err("%s", strings.TrimSpace(string(output)))
}
return string(output), nil
}
// copyDir recursively copies a directory
func copyDir(src, dst string) error {
entries, err := coreio.Local.List(src)
if err != nil {
return err
}
if err := coreio.Local.EnsureDir(dst); err != nil {
return err
}
for _, entry := range entries {
srcPath := filepath.Join(src, entry.Name())
dstPath := filepath.Join(dst, entry.Name())
if entry.IsDir() {
if err := copyDir(srcPath, dstPath); err != nil {
return err
}
} else {
if err := coreio.Copy(coreio.Local, srcPath, coreio.Local, dstPath); err != nil {
return err
}
}
}
return nil
}

View file

@ -1,185 +0,0 @@
package dev
import (
"context"
"fmt"
"sort"
"strings"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go/pkg/i18n"
)
// Health command flags
var (
healthRegistryPath string
healthVerbose bool
)
// AddHealthCommand adds the 'health' command to the given parent command.
func AddHealthCommand(parent *cli.Command) {
healthCmd := &cli.Command{
Use: "health",
Short: i18n.T("cmd.dev.health.short"),
Long: i18n.T("cmd.dev.health.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runHealth(healthRegistryPath, healthVerbose)
},
}
healthCmd.Flags().StringVar(&healthRegistryPath, "registry", "", i18n.T("common.flag.registry"))
healthCmd.Flags().BoolVarP(&healthVerbose, "verbose", "v", false, i18n.T("cmd.dev.health.flag.verbose"))
parent.AddCommand(healthCmd)
}
func runHealth(registryPath string, verbose bool) error {
ctx := context.Background()
// Load registry and get paths
reg, _, err := loadRegistryWithConfig(registryPath)
if err != nil {
return err
}
// Build paths and names for git operations
var paths []string
names := make(map[string]string)
for _, repo := range reg.List() {
if repo.IsGitRepo() {
paths = append(paths, repo.Path)
names[repo.Path] = repo.Name
}
}
if len(paths) == 0 {
cli.Text(i18n.T("cmd.dev.no_git_repos"))
return nil
}
// Get status for all repos
statuses := git.Status(ctx, git.StatusOptions{
Paths: paths,
Names: names,
})
// Sort for consistent verbose output
sort.Slice(statuses, func(i, j int) bool {
return statuses[i].Name < statuses[j].Name
})
// Aggregate stats
var (
totalRepos = len(statuses)
dirtyRepos []string
aheadRepos []string
behindRepos []string
errorRepos []string
)
for _, s := range statuses {
if s.Error != nil {
errorRepos = append(errorRepos, s.Name)
continue
}
if s.IsDirty() {
dirtyRepos = append(dirtyRepos, s.Name)
}
if s.HasUnpushed() {
aheadRepos = append(aheadRepos, s.Name)
}
if s.HasUnpulled() {
behindRepos = append(behindRepos, s.Name)
}
}
// Print summary line
cli.Blank()
printHealthSummary(totalRepos, dirtyRepos, aheadRepos, behindRepos, errorRepos)
cli.Blank()
// Verbose output
if verbose {
if len(dirtyRepos) > 0 {
cli.Print("%s %s\n", warningStyle.Render(i18n.T("cmd.dev.health.dirty_label")), formatRepoList(dirtyRepos))
}
if len(aheadRepos) > 0 {
cli.Print("%s %s\n", successStyle.Render(i18n.T("cmd.dev.health.ahead_label")), formatRepoList(aheadRepos))
}
if len(behindRepos) > 0 {
cli.Print("%s %s\n", warningStyle.Render(i18n.T("cmd.dev.health.behind_label")), formatRepoList(behindRepos))
}
if len(errorRepos) > 0 {
cli.Print("%s %s\n", errorStyle.Render(i18n.T("cmd.dev.health.errors_label")), formatRepoList(errorRepos))
}
cli.Blank()
}
return nil
}
func printHealthSummary(total int, dirty, ahead, behind, errors []string) {
parts := []string{
statusPart(total, i18n.T("cmd.dev.health.repos"), cli.ValueStyle),
}
// Dirty status
if len(dirty) > 0 {
parts = append(parts, statusPart(len(dirty), i18n.T("common.status.dirty"), cli.WarningStyle))
} else {
parts = append(parts, statusText(i18n.T("cmd.dev.status.clean"), cli.SuccessStyle))
}
// Push status
if len(ahead) > 0 {
parts = append(parts, statusPart(len(ahead), i18n.T("cmd.dev.health.to_push"), cli.ValueStyle))
} else {
parts = append(parts, statusText(i18n.T("common.status.synced"), cli.SuccessStyle))
}
// Pull status
if len(behind) > 0 {
parts = append(parts, statusPart(len(behind), i18n.T("cmd.dev.health.to_pull"), cli.WarningStyle))
} else {
parts = append(parts, statusText(i18n.T("common.status.up_to_date"), cli.SuccessStyle))
}
// Errors (only if any)
if len(errors) > 0 {
parts = append(parts, statusPart(len(errors), i18n.T("cmd.dev.health.errors"), cli.ErrorStyle))
}
cli.Text(statusLine(parts...))
}
func formatRepoList(reposList []string) string {
if len(reposList) <= 5 {
return joinRepos(reposList)
}
return joinRepos(reposList[:5]) + " " + i18n.T("cmd.dev.health.more", map[string]interface{}{"Count": len(reposList) - 5})
}
func joinRepos(reposList []string) string {
result := ""
for i, r := range reposList {
if i > 0 {
result += ", "
}
result += r
}
return result
}
func statusPart(count int, label string, style *cli.AnsiStyle) string {
return style.Render(fmt.Sprintf("%d %s", count, label))
}
func statusText(text string, style *cli.AnsiStyle) string {
return style.Render(text)
}
func statusLine(parts ...string) string {
return strings.Join(parts, " | ")
}

View file

@ -1,184 +0,0 @@
package dev
import (
"errors"
"sort"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io"
"forge.lthn.ai/core/go/pkg/repos"
)
// Impact-specific styles (aliases to shared)
var (
impactDirectStyle = cli.ErrorStyle
impactIndirectStyle = cli.WarningStyle
impactSafeStyle = cli.SuccessStyle
)
// Impact command flags
var impactRegistryPath string
// addImpactCommand adds the 'impact' command to the given parent command.
func addImpactCommand(parent *cli.Command) {
impactCmd := &cli.Command{
Use: "impact <repo-name>",
Short: i18n.T("cmd.dev.impact.short"),
Long: i18n.T("cmd.dev.impact.long"),
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
return runImpact(impactRegistryPath, args[0])
},
}
impactCmd.Flags().StringVar(&impactRegistryPath, "registry", "", i18n.T("common.flag.registry"))
parent.AddCommand(impactCmd)
}
func runImpact(registryPath string, repoName string) error {
// Find or use provided registry
var reg *repos.Registry
var err error
if registryPath != "" {
reg, err = repos.LoadRegistry(io.Local, registryPath)
if err != nil {
return cli.Wrap(err, "failed to load registry")
}
} else {
registryPath, err = repos.FindRegistry(io.Local)
if err == nil {
reg, err = repos.LoadRegistry(io.Local, registryPath)
if err != nil {
return cli.Wrap(err, "failed to load registry")
}
} else {
return errors.New(i18n.T("cmd.dev.impact.requires_registry"))
}
}
// Check repo exists
repo, exists := reg.Get(repoName)
if !exists {
return errors.New(i18n.T("error.repo_not_found", map[string]interface{}{"Name": repoName}))
}
// Build reverse dependency graph
dependents := buildDependentsGraph(reg)
// Find all affected repos (direct and transitive)
direct := dependents[repoName]
allAffected := findAllDependents(repoName, dependents)
// Separate direct vs indirect
directSet := make(map[string]bool)
for _, d := range direct {
directSet[d] = true
}
var indirect []string
for _, a := range allAffected {
if !directSet[a] {
indirect = append(indirect, a)
}
}
// Sort for consistent output
sort.Strings(direct)
sort.Strings(indirect)
// Print results
cli.Blank()
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.dev.impact.analysis_for")), repoNameStyle.Render(repoName))
if repo.Description != "" {
cli.Print("%s\n", dimStyle.Render(repo.Description))
}
cli.Blank()
if len(allAffected) == 0 {
cli.Print("%s %s\n", impactSafeStyle.Render("v"), i18n.T("cmd.dev.impact.no_dependents", map[string]interface{}{"Name": repoName}))
return nil
}
// Direct dependents
if len(direct) > 0 {
cli.Print("%s %s\n",
impactDirectStyle.Render("*"),
i18n.T("cmd.dev.impact.direct_dependents", map[string]interface{}{"Count": len(direct)}),
)
for _, d := range direct {
r, _ := reg.Get(d)
desc := ""
if r != nil && r.Description != "" {
desc = dimStyle.Render(" - " + cli.Truncate(r.Description, 40))
}
cli.Print(" %s%s\n", d, desc)
}
cli.Blank()
}
// Indirect dependents
if len(indirect) > 0 {
cli.Print("%s %s\n",
impactIndirectStyle.Render("o"),
i18n.T("cmd.dev.impact.transitive_dependents", map[string]interface{}{"Count": len(indirect)}),
)
for _, d := range indirect {
r, _ := reg.Get(d)
desc := ""
if r != nil && r.Description != "" {
desc = dimStyle.Render(" - " + cli.Truncate(r.Description, 40))
}
cli.Print(" %s%s\n", d, desc)
}
cli.Blank()
}
// Summary
cli.Print("%s %s\n",
dimStyle.Render(i18n.Label("summary")),
i18n.T("cmd.dev.impact.changes_affect", map[string]interface{}{
"Repo": repoNameStyle.Render(repoName),
"Affected": len(allAffected),
"Total": len(reg.Repos) - 1,
}),
)
return nil
}
// buildDependentsGraph creates a reverse dependency map
// key = repo, value = repos that depend on it
func buildDependentsGraph(reg *repos.Registry) map[string][]string {
dependents := make(map[string][]string)
for name, repo := range reg.Repos {
for _, dep := range repo.DependsOn {
dependents[dep] = append(dependents[dep], name)
}
}
return dependents
}
// findAllDependents recursively finds all repos that depend on the given repo
func findAllDependents(repoName string, dependents map[string][]string) []string {
visited := make(map[string]bool)
var result []string
var visit func(name string)
visit = func(name string) {
for _, dep := range dependents[name] {
if !visited[dep] {
visited[dep] = true
result = append(result, dep)
visit(dep) // Recurse for transitive deps
}
}
}
visit(repoName)
return result
}

View file

@ -1,208 +0,0 @@
package dev
import (
"encoding/json"
"errors"
"os/exec"
"sort"
"strings"
"time"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
// Issue-specific styles (aliases to shared)
var (
issueRepoStyle = cli.DimStyle
issueNumberStyle = cli.TitleStyle
issueTitleStyle = cli.ValueStyle
issueLabelStyle = cli.WarningStyle
issueAssigneeStyle = cli.SuccessStyle
issueAgeStyle = cli.DimStyle
)
// GitHubIssue represents a GitHub issue from the API.
type GitHubIssue struct {
Number int `json:"number"`
Title string `json:"title"`
State string `json:"state"`
CreatedAt time.Time `json:"createdAt"`
Author struct {
Login string `json:"login"`
} `json:"author"`
Assignees struct {
Nodes []struct {
Login string `json:"login"`
} `json:"nodes"`
} `json:"assignees"`
Labels struct {
Nodes []struct {
Name string `json:"name"`
} `json:"nodes"`
} `json:"labels"`
URL string `json:"url"`
// Added by us
RepoName string `json:"-"`
}
// Issues command flags
var (
issuesRegistryPath string
issuesLimit int
issuesAssignee string
)
// addIssuesCommand adds the 'issues' command to the given parent command.
func addIssuesCommand(parent *cli.Command) {
issuesCmd := &cli.Command{
Use: "issues",
Short: i18n.T("cmd.dev.issues.short"),
Long: i18n.T("cmd.dev.issues.long"),
RunE: func(cmd *cli.Command, args []string) error {
limit := issuesLimit
if limit == 0 {
limit = 10
}
return runIssues(issuesRegistryPath, limit, issuesAssignee)
},
}
issuesCmd.Flags().StringVar(&issuesRegistryPath, "registry", "", i18n.T("common.flag.registry"))
issuesCmd.Flags().IntVarP(&issuesLimit, "limit", "l", 10, i18n.T("cmd.dev.issues.flag.limit"))
issuesCmd.Flags().StringVarP(&issuesAssignee, "assignee", "a", "", i18n.T("cmd.dev.issues.flag.assignee"))
parent.AddCommand(issuesCmd)
}
func runIssues(registryPath string, limit int, assignee string) error {
// Check gh is available
if _, err := exec.LookPath("gh"); err != nil {
return errors.New(i18n.T("error.gh_not_found"))
}
// Find or use provided registry
reg, _, err := loadRegistryWithConfig(registryPath)
if err != nil {
return err
}
// Fetch issues sequentially (avoid GitHub rate limits)
var allIssues []GitHubIssue
var fetchErrors []error
repoList := reg.List()
for i, repo := range repoList {
repoFullName := cli.Sprintf("%s/%s", reg.Org, repo.Name)
cli.Print("\033[2K\r%s %d/%d %s", dimStyle.Render(i18n.T("i18n.progress.fetch")), i+1, len(repoList), repo.Name)
issues, err := fetchIssues(repoFullName, repo.Name, limit, assignee)
if err != nil {
fetchErrors = append(fetchErrors, cli.Wrap(err, repo.Name))
continue
}
allIssues = append(allIssues, issues...)
}
cli.Print("\033[2K\r") // Clear progress line
// Sort by created date (newest first)
sort.Slice(allIssues, func(i, j int) bool {
return allIssues[i].CreatedAt.After(allIssues[j].CreatedAt)
})
// Print issues
if len(allIssues) == 0 {
cli.Text(i18n.T("cmd.dev.issues.no_issues"))
return nil
}
cli.Print("\n%s\n\n", i18n.T("cmd.dev.issues.open_issues", map[string]interface{}{"Count": len(allIssues)}))
for _, issue := range allIssues {
printIssue(issue)
}
// Print any errors
if len(fetchErrors) > 0 {
cli.Blank()
for _, err := range fetchErrors {
cli.Print("%s %s\n", errorStyle.Render(i18n.Label("error")), err)
}
}
return nil
}
func fetchIssues(repoFullName, repoName string, limit int, assignee string) ([]GitHubIssue, error) {
args := []string{
"issue", "list",
"--repo", repoFullName,
"--state", "open",
"--limit", cli.Sprintf("%d", limit),
"--json", "number,title,state,createdAt,author,assignees,labels,url",
}
if assignee != "" {
args = append(args, "--assignee", assignee)
}
cmd := exec.Command("gh", args...)
output, err := cmd.Output()
if err != nil {
// Check if it's just "no issues" vs actual error
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := string(exitErr.Stderr)
if strings.Contains(stderr, "no issues") || strings.Contains(stderr, "Could not resolve") {
return nil, nil
}
return nil, cli.Err("%s", stderr)
}
return nil, err
}
var issues []GitHubIssue
if err := json.Unmarshal(output, &issues); err != nil {
return nil, err
}
// Tag with repo name
for i := range issues {
issues[i].RepoName = repoName
}
return issues, nil
}
func printIssue(issue GitHubIssue) {
// #42 [core-bio] Fix avatar upload
num := issueNumberStyle.Render(cli.Sprintf("#%d", issue.Number))
repo := issueRepoStyle.Render(cli.Sprintf("[%s]", issue.RepoName))
title := issueTitleStyle.Render(cli.Truncate(issue.Title, 60))
line := cli.Sprintf(" %s %s %s", num, repo, title)
// Add labels if any
if len(issue.Labels.Nodes) > 0 {
var labels []string
for _, l := range issue.Labels.Nodes {
labels = append(labels, l.Name)
}
line += " " + issueLabelStyle.Render("["+strings.Join(labels, ", ")+"]")
}
// Add assignee if any
if len(issue.Assignees.Nodes) > 0 {
var assignees []string
for _, a := range issue.Assignees.Nodes {
assignees = append(assignees, "@"+a.Login)
}
line += " " + issueAssigneeStyle.Render(strings.Join(assignees, ", "))
}
// Add age
age := cli.FormatAge(issue.CreatedAt)
line += " " + issueAgeStyle.Render(age)
cli.Text(line)
}

View file

@ -1,130 +0,0 @@
package dev
import (
"context"
"os/exec"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go/pkg/i18n"
)
// Pull command flags
var (
pullRegistryPath string
pullAll bool
)
// AddPullCommand adds the 'pull' command to the given parent command.
func AddPullCommand(parent *cli.Command) {
pullCmd := &cli.Command{
Use: "pull",
Short: i18n.T("cmd.dev.pull.short"),
Long: i18n.T("cmd.dev.pull.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runPull(pullRegistryPath, pullAll)
},
}
pullCmd.Flags().StringVar(&pullRegistryPath, "registry", "", i18n.T("common.flag.registry"))
pullCmd.Flags().BoolVar(&pullAll, "all", false, i18n.T("cmd.dev.pull.flag.all"))
parent.AddCommand(pullCmd)
}
func runPull(registryPath string, all bool) error {
ctx := context.Background()
// Find or use provided registry
reg, _, err := loadRegistryWithConfig(registryPath)
if err != nil {
return err
}
// Build paths and names for git operations
var paths []string
names := make(map[string]string)
for _, repo := range reg.List() {
if repo.IsGitRepo() {
paths = append(paths, repo.Path)
names[repo.Path] = repo.Name
}
}
if len(paths) == 0 {
cli.Text(i18n.T("cmd.dev.no_git_repos"))
return nil
}
// Get status for all repos
statuses := git.Status(ctx, git.StatusOptions{
Paths: paths,
Names: names,
})
// Find repos to pull
var toPull []git.RepoStatus
for _, s := range statuses {
if s.Error != nil {
continue
}
if all || s.HasUnpulled() {
toPull = append(toPull, s)
}
}
if len(toPull) == 0 {
cli.Text(i18n.T("cmd.dev.pull.all_up_to_date"))
return nil
}
// Show what we're pulling
if all {
cli.Print("\n%s\n\n", i18n.T("cmd.dev.pull.pulling_repos", map[string]interface{}{"Count": len(toPull)}))
} else {
cli.Print("\n%s\n\n", i18n.T("cmd.dev.pull.repos_behind", map[string]interface{}{"Count": len(toPull)}))
for _, s := range toPull {
cli.Print(" %s: %s\n",
repoNameStyle.Render(s.Name),
dimStyle.Render(i18n.T("cmd.dev.pull.commits_behind", map[string]interface{}{"Count": s.Behind})),
)
}
cli.Blank()
}
// Pull each repo
var succeeded, failed int
for _, s := range toPull {
cli.Print(" %s %s... ", dimStyle.Render(i18n.T("cmd.dev.pull.pulling")), s.Name)
err := gitPull(ctx, s.Path)
if err != nil {
cli.Print("%s\n", errorStyle.Render("x "+err.Error()))
failed++
} else {
cli.Print("%s\n", successStyle.Render("v"))
succeeded++
}
}
// Summary
cli.Blank()
cli.Print("%s", successStyle.Render(i18n.T("cmd.dev.pull.done_pulled", map[string]interface{}{"Count": succeeded})))
if failed > 0 {
cli.Print(", %s", errorStyle.Render(i18n.T("common.count.failed", map[string]interface{}{"Count": failed})))
}
cli.Blank()
return nil
}
func gitPull(ctx context.Context, path string) error {
cmd := exec.CommandContext(ctx, "git", "pull", "--ff-only")
cmd.Dir = path
output, err := cmd.CombinedOutput()
if err != nil {
return cli.Err("%s", string(output))
}
return nil
}

View file

@ -1,275 +0,0 @@
package dev
import (
"context"
"os"
"path/filepath"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-scm/git"
"forge.lthn.ai/core/go/pkg/i18n"
)
// Push command flags
var (
pushRegistryPath string
pushForce bool
)
// AddPushCommand adds the 'push' command to the given parent command.
func AddPushCommand(parent *cli.Command) {
pushCmd := &cli.Command{
Use: "push",
Short: i18n.T("cmd.dev.push.short"),
Long: i18n.T("cmd.dev.push.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runPush(pushRegistryPath, pushForce)
},
}
pushCmd.Flags().StringVar(&pushRegistryPath, "registry", "", i18n.T("common.flag.registry"))
pushCmd.Flags().BoolVarP(&pushForce, "force", "f", false, i18n.T("cmd.dev.push.flag.force"))
parent.AddCommand(pushCmd)
}
func runPush(registryPath string, force bool) error {
ctx := context.Background()
cwd, _ := os.Getwd()
// Check if current directory is a git repo (single-repo mode)
if registryPath == "" && isGitRepo(cwd) {
return runPushSingleRepo(ctx, cwd, force)
}
// Multi-repo mode: find or use provided registry
reg, _, err := loadRegistryWithConfig(registryPath)
if err != nil {
return err
}
// Build paths and names for git operations
var paths []string
names := make(map[string]string)
for _, repo := range reg.List() {
if repo.IsGitRepo() {
paths = append(paths, repo.Path)
names[repo.Path] = repo.Name
}
}
if len(paths) == 0 {
cli.Text(i18n.T("cmd.dev.no_git_repos"))
return nil
}
// Get status for all repos
statuses := git.Status(ctx, git.StatusOptions{
Paths: paths,
Names: names,
})
// Find repos with unpushed commits
var aheadRepos []git.RepoStatus
for _, s := range statuses {
if s.Error == nil && s.HasUnpushed() {
aheadRepos = append(aheadRepos, s)
}
}
if len(aheadRepos) == 0 {
cli.Text(i18n.T("cmd.dev.push.all_up_to_date"))
return nil
}
// Show repos to push
cli.Print("\n%s\n\n", i18n.T("common.count.repos_unpushed", map[string]interface{}{"Count": len(aheadRepos)}))
totalCommits := 0
for _, s := range aheadRepos {
cli.Print(" %s: %s\n",
repoNameStyle.Render(s.Name),
aheadStyle.Render(i18n.T("common.count.commits", map[string]interface{}{"Count": s.Ahead})),
)
totalCommits += s.Ahead
}
// Confirm unless --force
if !force {
cli.Blank()
if !cli.Confirm(i18n.T("cmd.dev.push.confirm_push", map[string]interface{}{"Commits": totalCommits, "Repos": len(aheadRepos)})) {
cli.Text(i18n.T("cli.aborted"))
return nil
}
}
cli.Blank()
// Push sequentially (SSH passphrase needs interaction)
var pushPaths []string
for _, s := range aheadRepos {
pushPaths = append(pushPaths, s.Path)
}
results := git.PushMultiple(ctx, pushPaths, names)
var succeeded, failed int
var divergedRepos []git.PushResult
for _, r := range results {
if r.Success {
cli.Print(" %s %s\n", successStyle.Render("v"), r.Name)
succeeded++
} else {
// Check if this is a non-fast-forward error (diverged branch)
if git.IsNonFastForward(r.Error) {
cli.Print(" %s %s: %s\n", warningStyle.Render("!"), r.Name, i18n.T("cmd.dev.push.diverged"))
divergedRepos = append(divergedRepos, r)
} else {
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), r.Name, r.Error)
}
failed++
}
}
// Handle diverged repos - offer to pull and retry
if len(divergedRepos) > 0 {
cli.Blank()
cli.Print("%s\n", i18n.T("cmd.dev.push.diverged_help"))
if cli.Confirm(i18n.T("cmd.dev.push.pull_and_retry")) {
cli.Blank()
for _, r := range divergedRepos {
cli.Print(" %s %s...\n", dimStyle.Render("↓"), r.Name)
if err := git.Pull(ctx, r.Path); err != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), r.Name, err)
continue
}
cli.Print(" %s %s...\n", dimStyle.Render("↑"), r.Name)
if err := git.Push(ctx, r.Path); err != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), r.Name, err)
continue
}
cli.Print(" %s %s\n", successStyle.Render("v"), r.Name)
succeeded++
failed--
}
}
}
// Summary
cli.Blank()
cli.Print("%s", successStyle.Render(i18n.T("cmd.dev.push.done_pushed", map[string]interface{}{"Count": succeeded})))
if failed > 0 {
cli.Print(", %s", errorStyle.Render(i18n.T("common.count.failed", map[string]interface{}{"Count": failed})))
}
cli.Blank()
return nil
}
// runPushSingleRepo handles push for a single repo (current directory).
func runPushSingleRepo(ctx context.Context, repoPath string, force bool) error {
repoName := filepath.Base(repoPath)
// Get status
statuses := git.Status(ctx, git.StatusOptions{
Paths: []string{repoPath},
Names: map[string]string{repoPath: repoName},
})
if len(statuses) == 0 {
return cli.Err("failed to get repo status")
}
s := statuses[0]
if s.Error != nil {
return s.Error
}
if !s.HasUnpushed() {
// Check if there are uncommitted changes
if s.IsDirty() {
cli.Print("%s: ", repoNameStyle.Render(s.Name))
if s.Modified > 0 {
cli.Print("%s ", dirtyStyle.Render(i18n.T("cmd.dev.modified", map[string]interface{}{"Count": s.Modified})))
}
if s.Untracked > 0 {
cli.Print("%s ", dirtyStyle.Render(i18n.T("cmd.dev.untracked", map[string]interface{}{"Count": s.Untracked})))
}
if s.Staged > 0 {
cli.Print("%s ", aheadStyle.Render(i18n.T("cmd.dev.staged", map[string]interface{}{"Count": s.Staged})))
}
cli.Blank()
cli.Blank()
if cli.Confirm(i18n.T("cmd.dev.push.uncommitted_changes_commit")) {
cli.Blank()
// Use edit-enabled commit if only untracked files (may need .gitignore fix)
var err error
if s.Modified == 0 && s.Staged == 0 && s.Untracked > 0 {
err = claudeEditCommit(ctx, repoPath, repoName, "")
} else {
err = runCommitSingleRepo(ctx, repoPath, false)
}
if err != nil {
return err
}
// Re-check - only push if Claude created commits
newStatuses := git.Status(ctx, git.StatusOptions{
Paths: []string{repoPath},
Names: map[string]string{repoPath: repoName},
})
if len(newStatuses) > 0 && newStatuses[0].HasUnpushed() {
return runPushSingleRepo(ctx, repoPath, force)
}
}
return nil
}
cli.Text(i18n.T("cmd.dev.push.all_up_to_date"))
return nil
}
// Show commits to push
cli.Print("%s: %s\n", repoNameStyle.Render(s.Name),
aheadStyle.Render(i18n.T("common.count.commits", map[string]interface{}{"Count": s.Ahead})))
// Confirm unless --force
if !force {
cli.Blank()
if !cli.Confirm(i18n.T("cmd.dev.push.confirm_push", map[string]interface{}{"Commits": s.Ahead, "Repos": 1})) {
cli.Text(i18n.T("cli.aborted"))
return nil
}
}
cli.Blank()
// Push
err := git.Push(ctx, repoPath)
if err != nil {
if git.IsNonFastForward(err) {
cli.Print(" %s %s: %s\n", warningStyle.Render("!"), repoName, i18n.T("cmd.dev.push.diverged"))
cli.Blank()
cli.Print("%s\n", i18n.T("cmd.dev.push.diverged_help"))
if cli.Confirm(i18n.T("cmd.dev.push.pull_and_retry")) {
cli.Blank()
cli.Print(" %s %s...\n", dimStyle.Render("↓"), repoName)
if pullErr := git.Pull(ctx, repoPath); pullErr != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), repoName, pullErr)
return pullErr
}
cli.Print(" %s %s...\n", dimStyle.Render("↑"), repoName)
if pushErr := git.Push(ctx, repoPath); pushErr != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), repoName, pushErr)
return pushErr
}
cli.Print(" %s %s\n", successStyle.Render("v"), repoName)
return nil
}
}
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), repoName, err)
return err
}
cli.Print(" %s %s\n", successStyle.Render("v"), repoName)
return nil
}

View file

@ -1,237 +0,0 @@
package dev
import (
"encoding/json"
"errors"
"os/exec"
"sort"
"strings"
"time"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go/pkg/i18n"
)
// PR-specific styles (aliases to shared)
var (
prNumberStyle = cli.NumberStyle
prTitleStyle = cli.ValueStyle
prAuthorStyle = cli.InfoStyle
prApprovedStyle = cli.SuccessStyle
prChangesStyle = cli.WarningStyle
prPendingStyle = cli.DimStyle
prDraftStyle = cli.DimStyle
)
// GitHubPR represents a GitHub pull request.
type GitHubPR struct {
Number int `json:"number"`
Title string `json:"title"`
State string `json:"state"`
IsDraft bool `json:"isDraft"`
CreatedAt time.Time `json:"createdAt"`
Author struct {
Login string `json:"login"`
} `json:"author"`
ReviewDecision string `json:"reviewDecision"`
Reviews struct {
Nodes []struct {
State string `json:"state"`
Author struct {
Login string `json:"login"`
} `json:"author"`
} `json:"nodes"`
} `json:"reviews"`
URL string `json:"url"`
// Added by us
RepoName string `json:"-"`
}
// Reviews command flags
var (
reviewsRegistryPath string
reviewsAuthor string
reviewsShowAll bool
)
// addReviewsCommand adds the 'reviews' command to the given parent command.
func addReviewsCommand(parent *cli.Command) {
reviewsCmd := &cli.Command{
Use: "reviews",
Short: i18n.T("cmd.dev.reviews.short"),
Long: i18n.T("cmd.dev.reviews.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runReviews(reviewsRegistryPath, reviewsAuthor, reviewsShowAll)
},
}
reviewsCmd.Flags().StringVar(&reviewsRegistryPath, "registry", "", i18n.T("common.flag.registry"))
reviewsCmd.Flags().StringVar(&reviewsAuthor, "author", "", i18n.T("cmd.dev.reviews.flag.author"))
reviewsCmd.Flags().BoolVar(&reviewsShowAll, "all", false, i18n.T("cmd.dev.reviews.flag.all"))
parent.AddCommand(reviewsCmd)
}
func runReviews(registryPath string, author string, showAll bool) error {
// Check gh is available
if _, err := exec.LookPath("gh"); err != nil {
return errors.New(i18n.T("error.gh_not_found"))
}
// Find or use provided registry
reg, _, err := loadRegistryWithConfig(registryPath)
if err != nil {
return err
}
// Fetch PRs sequentially (avoid GitHub rate limits)
var allPRs []GitHubPR
var fetchErrors []error
repoList := reg.List()
for i, repo := range repoList {
repoFullName := cli.Sprintf("%s/%s", reg.Org, repo.Name)
cli.Print("\033[2K\r%s %d/%d %s", dimStyle.Render(i18n.T("i18n.progress.fetch")), i+1, len(repoList), repo.Name)
prs, err := fetchPRs(repoFullName, repo.Name, author)
if err != nil {
fetchErrors = append(fetchErrors, cli.Wrap(err, repo.Name))
continue
}
for _, pr := range prs {
// Filter drafts unless --all
if !showAll && pr.IsDraft {
continue
}
allPRs = append(allPRs, pr)
}
}
cli.Print("\033[2K\r") // Clear progress line
// Sort: pending review first, then by date
sort.Slice(allPRs, func(i, j int) bool {
// Pending reviews come first
iPending := allPRs[i].ReviewDecision == "" || allPRs[i].ReviewDecision == "REVIEW_REQUIRED"
jPending := allPRs[j].ReviewDecision == "" || allPRs[j].ReviewDecision == "REVIEW_REQUIRED"
if iPending != jPending {
return iPending
}
return allPRs[i].CreatedAt.After(allPRs[j].CreatedAt)
})
// Print PRs
if len(allPRs) == 0 {
cli.Text(i18n.T("cmd.dev.reviews.no_prs"))
return nil
}
// Count by status
var pending, approved, changesRequested int
for _, pr := range allPRs {
switch pr.ReviewDecision {
case "APPROVED":
approved++
case "CHANGES_REQUESTED":
changesRequested++
default:
pending++
}
}
cli.Blank()
cli.Print("%s", i18n.T("cmd.dev.reviews.open_prs", map[string]interface{}{"Count": len(allPRs)}))
if pending > 0 {
cli.Print(" * %s", prPendingStyle.Render(i18n.T("common.count.pending", map[string]interface{}{"Count": pending})))
}
if approved > 0 {
cli.Print(" * %s", prApprovedStyle.Render(i18n.T("cmd.dev.reviews.approved", map[string]interface{}{"Count": approved})))
}
if changesRequested > 0 {
cli.Print(" * %s", prChangesStyle.Render(i18n.T("cmd.dev.reviews.changes_requested", map[string]interface{}{"Count": changesRequested})))
}
cli.Blank()
cli.Blank()
for _, pr := range allPRs {
printPR(pr)
}
// Print any errors
if len(fetchErrors) > 0 {
cli.Blank()
for _, err := range fetchErrors {
cli.Print("%s %s\n", errorStyle.Render(i18n.Label("error")), err)
}
}
return nil
}
func fetchPRs(repoFullName, repoName string, author string) ([]GitHubPR, error) {
args := []string{
"pr", "list",
"--repo", repoFullName,
"--state", "open",
"--json", "number,title,state,isDraft,createdAt,author,reviewDecision,reviews,url",
}
if author != "" {
args = append(args, "--author", author)
}
cmd := exec.Command("gh", args...)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := string(exitErr.Stderr)
if strings.Contains(stderr, "no pull requests") || strings.Contains(stderr, "Could not resolve") {
return nil, nil
}
return nil, cli.Err("%s", stderr)
}
return nil, err
}
var prs []GitHubPR
if err := json.Unmarshal(output, &prs); err != nil {
return nil, err
}
// Tag with repo name
for i := range prs {
prs[i].RepoName = repoName
}
return prs, nil
}
func printPR(pr GitHubPR) {
// #12 [core-php] Webhook validation
num := prNumberStyle.Render(cli.Sprintf("#%d", pr.Number))
repo := issueRepoStyle.Render(cli.Sprintf("[%s]", pr.RepoName))
title := prTitleStyle.Render(cli.Truncate(pr.Title, 50))
author := prAuthorStyle.Render("@" + pr.Author.Login)
// Review status
var status string
switch pr.ReviewDecision {
case "APPROVED":
status = prApprovedStyle.Render(i18n.T("cmd.dev.reviews.status_approved"))
case "CHANGES_REQUESTED":
status = prChangesStyle.Render(i18n.T("cmd.dev.reviews.status_changes"))
default:
status = prPendingStyle.Render(i18n.T("cmd.dev.reviews.status_pending"))
}
// Draft indicator
draft := ""
if pr.IsDraft {
draft = prDraftStyle.Render(" " + i18n.T("cmd.dev.reviews.draft"))
}
age := cli.FormatAge(pr.CreatedAt)
cli.Print(" %s %s %s%s %s %s %s\n", num, repo, title, draft, author, status, issueAgeStyle.Render(age))
}

View file

@ -1,174 +0,0 @@
package dev
import (
"bytes"
"go/ast"
"go/parser"
"go/token"
"path/filepath"
"text/template"
"forge.lthn.ai/core/cli/pkg/cli" // Added
"forge.lthn.ai/core/go/pkg/i18n" // Added
coreio "forge.lthn.ai/core/go/pkg/io"
// Added
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// addSyncCommand adds the 'sync' command to the given parent command.
func addSyncCommand(parent *cli.Command) {
syncCmd := &cli.Command{
Use: "sync",
Short: i18n.T("cmd.dev.sync.short"),
Long: i18n.T("cmd.dev.sync.long"),
RunE: func(cmd *cli.Command, args []string) error {
if err := runSync(); err != nil {
return cli.Wrap(err, i18n.Label("error"))
}
cli.Text(i18n.T("i18n.done.sync", "public APIs"))
return nil
},
}
parent.AddCommand(syncCmd)
}
type symbolInfo struct {
Name string
Kind string // "var", "func", "type", "const"
}
func runSync() error {
pkgDir := "pkg"
internalDirs, err := coreio.Local.List(pkgDir)
if err != nil {
return cli.Wrap(err, "failed to read pkg directory")
}
for _, dir := range internalDirs {
if !dir.IsDir() || dir.Name() == "core" {
continue
}
serviceName := dir.Name()
internalFile := filepath.Join(pkgDir, serviceName, serviceName+".go")
publicDir := serviceName
publicFile := filepath.Join(publicDir, serviceName+".go")
if !coreio.Local.IsFile(internalFile) {
continue
}
symbols, err := getExportedSymbols(internalFile)
if err != nil {
return cli.Wrap(err, cli.Sprintf("error getting symbols for service '%s'", serviceName))
}
if err := generatePublicAPIFile(publicDir, publicFile, serviceName, symbols); err != nil {
return cli.Wrap(err, cli.Sprintf("error generating public API file for service '%s'", serviceName))
}
}
return nil
}
func getExportedSymbols(path string) ([]symbolInfo, error) {
// ParseFile expects a filename/path and reads it using os.Open by default if content is nil.
// Since we want to use our Medium abstraction, we should read the file content first.
content, err := coreio.Local.Read(path)
if err != nil {
return nil, err
}
fset := token.NewFileSet()
// ParseFile can take content as string (src argument).
node, err := parser.ParseFile(fset, path, content, parser.ParseComments)
if err != nil {
return nil, err
}
var symbols []symbolInfo
for name, obj := range node.Scope.Objects {
if ast.IsExported(name) {
kind := "unknown"
switch obj.Kind {
case ast.Con:
kind = "const"
case ast.Var:
kind = "var"
case ast.Fun:
kind = "func"
case ast.Typ:
kind = "type"
}
if kind != "unknown" {
symbols = append(symbols, symbolInfo{Name: name, Kind: kind})
}
}
}
return symbols, nil
}
const publicAPITemplate = `// package {{.ServiceName}} provides the public API for the {{.ServiceName}} service.
package {{.ServiceName}}
import (
// Import the internal implementation with an alias.
impl "forge.lthn.ai/core/cli/{{.ServiceName}}"
// Import the core contracts to re-export the interface.
"forge.lthn.ai/core/cli/core"
)
{{range .Symbols}}
{{- if eq .Kind "type"}}
// {{.Name}} is the public type for the {{.Name}} service. It is a type alias
// to the underlying implementation, making it transparent to the user.
type {{.Name}} = impl.{{.Name}}
{{else if eq .Kind "const"}}
// {{.Name}} is a public constant that points to the real constant in the implementation package.
const {{.Name}} = impl.{{.Name}}
{{else if eq .Kind "var"}}
// {{.Name}} is a public variable that points to the real variable in the implementation package.
var {{.Name}} = impl.{{.Name}}
{{else if eq .Kind "func"}}
// {{.Name}} is a public function that points to the real function in the implementation package.
var {{.Name}} = impl.{{.Name}}
{{end}}
{{end}}
// {{.InterfaceName}} is the public interface for the {{.ServiceName}} service.
type {{.InterfaceName}} = core.{{.InterfaceName}}
`
func generatePublicAPIFile(dir, path, serviceName string, symbols []symbolInfo) error {
if err := coreio.Local.EnsureDir(dir); err != nil {
return err
}
tmpl, err := template.New("publicAPI").Parse(publicAPITemplate)
if err != nil {
return err
}
tcaser := cases.Title(language.English)
interfaceName := tcaser.String(serviceName)
data := struct {
ServiceName string
Symbols []symbolInfo
InterfaceName string
}{
ServiceName: serviceName,
Symbols: symbols,
InterfaceName: interfaceName,
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return err
}
return coreio.Local.Write(path, buf.String())
}

View file

@ -1,510 +0,0 @@
package dev
import (
"context"
"errors"
"os"
"time"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-devops/devops"
"forge.lthn.ai/core/go/pkg/i18n"
"forge.lthn.ai/core/go/pkg/io"
)
// addVMCommands adds the dev environment VM commands to the dev parent command.
// These are added as direct subcommands: core dev install, core dev boot, etc.
func addVMCommands(parent *cli.Command) {
addVMInstallCommand(parent)
addVMBootCommand(parent)
addVMStopCommand(parent)
addVMStatusCommand(parent)
addVMShellCommand(parent)
addVMServeCommand(parent)
addVMTestCommand(parent)
addVMClaudeCommand(parent)
addVMUpdateCommand(parent)
}
// addVMInstallCommand adds the 'dev install' command.
func addVMInstallCommand(parent *cli.Command) {
installCmd := &cli.Command{
Use: "install",
Short: i18n.T("cmd.dev.vm.install.short"),
Long: i18n.T("cmd.dev.vm.install.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runVMInstall()
},
}
parent.AddCommand(installCmd)
}
func runVMInstall() error {
d, err := devops.New(io.Local)
if err != nil {
return err
}
if d.IsInstalled() {
cli.Text(successStyle.Render(i18n.T("cmd.dev.vm.already_installed")))
cli.Blank()
cli.Text(i18n.T("cmd.dev.vm.check_updates", map[string]interface{}{"Command": dimStyle.Render("core dev update")}))
return nil
}
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("image")), devops.ImageName())
cli.Blank()
cli.Text(i18n.T("cmd.dev.vm.downloading"))
cli.Blank()
ctx := context.Background()
start := time.Now()
var lastProgress int64
err = d.Install(ctx, func(downloaded, total int64) {
if total > 0 {
pct := int(float64(downloaded) / float64(total) * 100)
if pct != int(float64(lastProgress)/float64(total)*100) {
cli.Print("\r%s %d%%", dimStyle.Render(i18n.T("cmd.dev.vm.progress_label")), pct)
lastProgress = downloaded
}
}
})
cli.Blank() // Clear progress line
if err != nil {
return cli.Wrap(err, "install failed")
}
elapsed := time.Since(start).Round(time.Second)
cli.Blank()
cli.Text(i18n.T("cmd.dev.vm.installed_in", map[string]interface{}{"Duration": elapsed}))
cli.Blank()
cli.Text(i18n.T("cmd.dev.vm.start_with", map[string]interface{}{"Command": dimStyle.Render("core dev boot")}))
return nil
}
// VM boot command flags
var (
vmBootMemory int
vmBootCPUs int
vmBootFresh bool
)
// addVMBootCommand adds the 'devops boot' command.
func addVMBootCommand(parent *cli.Command) {
bootCmd := &cli.Command{
Use: "boot",
Short: i18n.T("cmd.dev.vm.boot.short"),
Long: i18n.T("cmd.dev.vm.boot.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runVMBoot(vmBootMemory, vmBootCPUs, vmBootFresh)
},
}
bootCmd.Flags().IntVar(&vmBootMemory, "memory", 0, i18n.T("cmd.dev.vm.boot.flag.memory"))
bootCmd.Flags().IntVar(&vmBootCPUs, "cpus", 0, i18n.T("cmd.dev.vm.boot.flag.cpus"))
bootCmd.Flags().BoolVar(&vmBootFresh, "fresh", false, i18n.T("cmd.dev.vm.boot.flag.fresh"))
parent.AddCommand(bootCmd)
}
func runVMBoot(memory, cpus int, fresh bool) error {
d, err := devops.New(io.Local)
if err != nil {
return err
}
if !d.IsInstalled() {
return errors.New(i18n.T("cmd.dev.vm.not_installed"))
}
opts := devops.DefaultBootOptions()
if memory > 0 {
opts.Memory = memory
}
if cpus > 0 {
opts.CPUs = cpus
}
opts.Fresh = fresh
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.dev.vm.config_label")), i18n.T("cmd.dev.vm.config_value", map[string]interface{}{"Memory": opts.Memory, "CPUs": opts.CPUs}))
cli.Blank()
cli.Text(i18n.T("cmd.dev.vm.booting"))
ctx := context.Background()
if err := d.Boot(ctx, opts); err != nil {
return err
}
cli.Blank()
cli.Text(successStyle.Render(i18n.T("cmd.dev.vm.running")))
cli.Blank()
cli.Text(i18n.T("cmd.dev.vm.connect_with", map[string]interface{}{"Command": dimStyle.Render("core dev shell")}))
cli.Print("%s %s\n", i18n.T("cmd.dev.vm.ssh_port"), dimStyle.Render("2222"))
return nil
}
// addVMStopCommand adds the 'devops stop' command.
func addVMStopCommand(parent *cli.Command) {
stopCmd := &cli.Command{
Use: "stop",
Short: i18n.T("cmd.dev.vm.stop.short"),
Long: i18n.T("cmd.dev.vm.stop.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runVMStop()
},
}
parent.AddCommand(stopCmd)
}
func runVMStop() error {
d, err := devops.New(io.Local)
if err != nil {
return err
}
ctx := context.Background()
running, err := d.IsRunning(ctx)
if err != nil {
return err
}
if !running {
cli.Text(dimStyle.Render(i18n.T("cmd.dev.vm.not_running")))
return nil
}
cli.Text(i18n.T("cmd.dev.vm.stopping"))
if err := d.Stop(ctx); err != nil {
return err
}
cli.Text(successStyle.Render(i18n.T("common.status.stopped")))
return nil
}
// addVMStatusCommand adds the 'devops status' command.
func addVMStatusCommand(parent *cli.Command) {
statusCmd := &cli.Command{
Use: "vm-status",
Short: i18n.T("cmd.dev.vm.status.short"),
Long: i18n.T("cmd.dev.vm.status.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runVMStatus()
},
}
parent.AddCommand(statusCmd)
}
func runVMStatus() error {
d, err := devops.New(io.Local)
if err != nil {
return err
}
ctx := context.Background()
status, err := d.Status(ctx)
if err != nil {
return err
}
cli.Text(headerStyle.Render(i18n.T("cmd.dev.vm.status_title")))
cli.Blank()
// Installation status
if status.Installed {
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.dev.vm.installed_label")), successStyle.Render(i18n.T("cmd.dev.vm.installed_yes")))
if status.ImageVersion != "" {
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("version")), status.ImageVersion)
}
} else {
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.dev.vm.installed_label")), errorStyle.Render(i18n.T("cmd.dev.vm.installed_no")))
cli.Blank()
cli.Text(i18n.T("cmd.dev.vm.install_with", map[string]interface{}{"Command": dimStyle.Render("core dev install")}))
return nil
}
cli.Blank()
// Running status
if status.Running {
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("status")), successStyle.Render(i18n.T("common.status.running")))
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.dev.vm.container_label")), status.ContainerID[:8])
cli.Print("%s %dMB\n", dimStyle.Render(i18n.T("cmd.dev.vm.memory_label")), status.Memory)
cli.Print("%s %d\n", dimStyle.Render(i18n.T("cmd.dev.vm.cpus_label")), status.CPUs)
cli.Print("%s %d\n", dimStyle.Render(i18n.T("cmd.dev.vm.ssh_port")), status.SSHPort)
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.dev.vm.uptime_label")), formatVMUptime(status.Uptime))
} else {
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("status")), dimStyle.Render(i18n.T("common.status.stopped")))
cli.Blank()
cli.Text(i18n.T("cmd.dev.vm.start_with", map[string]interface{}{"Command": dimStyle.Render("core dev boot")}))
}
return nil
}
func formatVMUptime(d time.Duration) string {
if d < time.Minute {
return cli.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return cli.Sprintf("%dm", int(d.Minutes()))
}
if d < 24*time.Hour {
return cli.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
}
return cli.Sprintf("%dd %dh", int(d.Hours()/24), int(d.Hours())%24)
}
// VM shell command flags
var vmShellConsole bool
// addVMShellCommand adds the 'devops shell' command.
func addVMShellCommand(parent *cli.Command) {
shellCmd := &cli.Command{
Use: "shell [-- command...]",
Short: i18n.T("cmd.dev.vm.shell.short"),
Long: i18n.T("cmd.dev.vm.shell.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runVMShell(vmShellConsole, args)
},
}
shellCmd.Flags().BoolVar(&vmShellConsole, "console", false, i18n.T("cmd.dev.vm.shell.flag.console"))
parent.AddCommand(shellCmd)
}
func runVMShell(console bool, command []string) error {
d, err := devops.New(io.Local)
if err != nil {
return err
}
opts := devops.ShellOptions{
Console: console,
Command: command,
}
ctx := context.Background()
return d.Shell(ctx, opts)
}
// VM serve command flags
var (
vmServePort int
vmServePath string
)
// addVMServeCommand adds the 'devops serve' command.
func addVMServeCommand(parent *cli.Command) {
serveCmd := &cli.Command{
Use: "serve",
Short: i18n.T("cmd.dev.vm.serve.short"),
Long: i18n.T("cmd.dev.vm.serve.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runVMServe(vmServePort, vmServePath)
},
}
serveCmd.Flags().IntVarP(&vmServePort, "port", "p", 0, i18n.T("cmd.dev.vm.serve.flag.port"))
serveCmd.Flags().StringVar(&vmServePath, "path", "", i18n.T("cmd.dev.vm.serve.flag.path"))
parent.AddCommand(serveCmd)
}
func runVMServe(port int, path string) error {
d, err := devops.New(io.Local)
if err != nil {
return err
}
projectDir, err := os.Getwd()
if err != nil {
return err
}
opts := devops.ServeOptions{
Port: port,
Path: path,
}
ctx := context.Background()
return d.Serve(ctx, projectDir, opts)
}
// VM test command flags
var vmTestName string
// addVMTestCommand adds the 'devops test' command.
func addVMTestCommand(parent *cli.Command) {
testCmd := &cli.Command{
Use: "test [-- command...]",
Short: i18n.T("cmd.dev.vm.test.short"),
Long: i18n.T("cmd.dev.vm.test.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runVMTest(vmTestName, args)
},
}
testCmd.Flags().StringVarP(&vmTestName, "name", "n", "", i18n.T("cmd.dev.vm.test.flag.name"))
parent.AddCommand(testCmd)
}
func runVMTest(name string, command []string) error {
d, err := devops.New(io.Local)
if err != nil {
return err
}
projectDir, err := os.Getwd()
if err != nil {
return err
}
opts := devops.TestOptions{
Name: name,
Command: command,
}
ctx := context.Background()
return d.Test(ctx, projectDir, opts)
}
// VM claude command flags
var (
vmClaudeNoAuth bool
vmClaudeModel string
vmClaudeAuthFlags []string
)
// addVMClaudeCommand adds the 'devops claude' command.
func addVMClaudeCommand(parent *cli.Command) {
claudeCmd := &cli.Command{
Use: "claude",
Short: i18n.T("cmd.dev.vm.claude.short"),
Long: i18n.T("cmd.dev.vm.claude.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runVMClaude(vmClaudeNoAuth, vmClaudeModel, vmClaudeAuthFlags)
},
}
claudeCmd.Flags().BoolVar(&vmClaudeNoAuth, "no-auth", false, i18n.T("cmd.dev.vm.claude.flag.no_auth"))
claudeCmd.Flags().StringVarP(&vmClaudeModel, "model", "m", "", i18n.T("cmd.dev.vm.claude.flag.model"))
claudeCmd.Flags().StringSliceVar(&vmClaudeAuthFlags, "auth", nil, i18n.T("cmd.dev.vm.claude.flag.auth"))
parent.AddCommand(claudeCmd)
}
func runVMClaude(noAuth bool, model string, authFlags []string) error {
d, err := devops.New(io.Local)
if err != nil {
return err
}
projectDir, err := os.Getwd()
if err != nil {
return err
}
opts := devops.ClaudeOptions{
NoAuth: noAuth,
Model: model,
Auth: authFlags,
}
ctx := context.Background()
return d.Claude(ctx, projectDir, opts)
}
// VM update command flags
var vmUpdateApply bool
// addVMUpdateCommand adds the 'devops update' command.
func addVMUpdateCommand(parent *cli.Command) {
updateCmd := &cli.Command{
Use: "update",
Short: i18n.T("cmd.dev.vm.update.short"),
Long: i18n.T("cmd.dev.vm.update.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runVMUpdate(vmUpdateApply)
},
}
updateCmd.Flags().BoolVar(&vmUpdateApply, "apply", false, i18n.T("cmd.dev.vm.update.flag.apply"))
parent.AddCommand(updateCmd)
}
func runVMUpdate(apply bool) error {
d, err := devops.New(io.Local)
if err != nil {
return err
}
ctx := context.Background()
cli.Text(i18n.T("common.progress.checking_updates"))
cli.Blank()
current, latest, hasUpdate, err := d.CheckUpdate(ctx)
if err != nil {
return cli.Wrap(err, "failed to check for updates")
}
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("current")), valueStyle.Render(current))
cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.dev.vm.latest_label")), valueStyle.Render(latest))
cli.Blank()
if !hasUpdate {
cli.Text(successStyle.Render(i18n.T("cmd.dev.vm.up_to_date")))
return nil
}
cli.Text(warningStyle.Render(i18n.T("cmd.dev.vm.update_available")))
cli.Blank()
if !apply {
cli.Text(i18n.T("cmd.dev.vm.run_to_update", map[string]interface{}{"Command": dimStyle.Render("core dev update --apply")}))
return nil
}
// Stop if running
running, _ := d.IsRunning(ctx)
if running {
cli.Text(i18n.T("cmd.dev.vm.stopping_current"))
_ = d.Stop(ctx)
}
cli.Text(i18n.T("cmd.dev.vm.downloading_update"))
cli.Blank()
start := time.Now()
err = d.Install(ctx, func(downloaded, total int64) {
if total > 0 {
pct := int(float64(downloaded) / float64(total) * 100)
cli.Print("\r%s %d%%", dimStyle.Render(i18n.T("cmd.dev.vm.progress_label")), pct)
}
})
cli.Blank()
if err != nil {
return cli.Wrap(err, "update failed")
}
elapsed := time.Since(start).Round(time.Second)
cli.Blank()
cli.Text(i18n.T("cmd.dev.vm.updated_in", map[string]interface{}{"Duration": elapsed}))
return nil
}

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