Compare commits
16 Commits
docs/plan-
...
v0.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 28f7dc199e | |||
|
|
73eb852361 | ||
|
|
a42274a770 | ||
| f0d9c4dc09 | |||
|
|
4bf1c1fe60 | ||
|
|
6c6f4022a0 | ||
| 1328b3cc94 | |||
| 87a62c0d6c | |||
| 3e09dc0ac6 | |||
| b59e02aebe | |||
| 4713110e32 | |||
| 6c6b49b45c | |||
| 59c43188e5 | |||
| f0e242293f | |||
|
|
50870dd369 | ||
| b93f1d14f0 |
41
.gitea/workflows/ci.yml
Normal file
41
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['**']
|
||||||
|
tags-ignore: ['v*']
|
||||||
|
pull_request:
|
||||||
|
branches: ['**']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.26'
|
||||||
|
|
||||||
|
- name: Cache Go modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/go-build
|
||||||
|
~/go/pkg/mod
|
||||||
|
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-go-
|
||||||
|
|
||||||
|
- name: Download dependencies
|
||||||
|
run: go mod download
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: go test ./...
|
||||||
|
|
||||||
|
- name: Build amcs-server
|
||||||
|
run: go build -o /dev/null ./cmd/amcs-server
|
||||||
|
|
||||||
|
- name: Build amcs-cli
|
||||||
|
run: go build -o /dev/null ./cmd/amcs-cli
|
||||||
102
.gitea/workflows/release.yml
Normal file
102
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_SERVER: https://git.warky.dev
|
||||||
|
GITEA_REPO: wdevs/amcs
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.26'
|
||||||
|
|
||||||
|
- name: Cache Go modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/go-build
|
||||||
|
~/go/pkg/mod
|
||||||
|
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-go-
|
||||||
|
|
||||||
|
- name: Download dependencies
|
||||||
|
run: go mod download
|
||||||
|
|
||||||
|
- name: Set build vars
|
||||||
|
id: vars
|
||||||
|
run: |
|
||||||
|
echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT
|
||||||
|
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
echo "BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build release binaries
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.vars.outputs.VERSION }}"
|
||||||
|
COMMIT="${{ steps.vars.outputs.COMMIT }}"
|
||||||
|
BUILD_DATE="${{ steps.vars.outputs.BUILD_DATE }}"
|
||||||
|
LDFLAGS="-s -w -X git.warky.dev/wdevs/amcs/internal/buildinfo.Version=${VERSION} -X git.warky.dev/wdevs/amcs/internal/buildinfo.TagName=${VERSION} -X git.warky.dev/wdevs/amcs/internal/buildinfo.Commit=${COMMIT} -X git.warky.dev/wdevs/amcs/internal/buildinfo.BuildDate=${BUILD_DATE}"
|
||||||
|
mkdir -p dist
|
||||||
|
for BINARY in amcs-server amcs-cli; do
|
||||||
|
CMD="./cmd/${BINARY}"
|
||||||
|
for PLATFORM in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do
|
||||||
|
OS="${PLATFORM%/*}"
|
||||||
|
ARCH="${PLATFORM#*/}"
|
||||||
|
EXT=""
|
||||||
|
[ "$OS" = "windows" ] && EXT=".exe"
|
||||||
|
OUTPUT="dist/${BINARY}-${OS}-${ARCH}${EXT}"
|
||||||
|
echo "Building ${OUTPUT}..."
|
||||||
|
GOOS=$OS GOARCH=$ARCH go build -ldflags "${LDFLAGS}" -o "${OUTPUT}" "${CMD}"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
cd dist && sha256sum * > checksums.txt && cd ..
|
||||||
|
|
||||||
|
- name: Create Gitea Release
|
||||||
|
id: create_release
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.vars.outputs.VERSION }}"
|
||||||
|
BODY=$(python3 <<'PY'
|
||||||
|
import json, subprocess, os
|
||||||
|
version = os.environ['VERSION']
|
||||||
|
commit = subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD'], text=True).strip()
|
||||||
|
body = f"## {version}\n\nBuilt from commit {commit}.\n\nSee `checksums.txt` to verify downloads."
|
||||||
|
print(json.dumps({
|
||||||
|
'tag_name': version,
|
||||||
|
'name': version,
|
||||||
|
'body': body,
|
||||||
|
'draft': False,
|
||||||
|
'prerelease': False,
|
||||||
|
}))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
RESPONSE=$(curl -fsS -X POST "${{ env.GITEA_SERVER }}/api/v1/repos/${{ env.GITEA_REPO }}/releases" \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$BODY")
|
||||||
|
RELEASE_ID=$(printf '%s' "$RESPONSE" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')
|
||||||
|
echo "RELEASE_ID=${RELEASE_ID}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Upload release assets
|
||||||
|
run: |
|
||||||
|
RELEASE_ID="${{ steps.create_release.outputs.RELEASE_ID }}"
|
||||||
|
for f in dist/*; do
|
||||||
|
name=$(basename "$f")
|
||||||
|
echo "Uploading ${name}..."
|
||||||
|
curl -fsS -X POST \
|
||||||
|
"${{ env.GITEA_SERVER }}/api/v1/repos/${{ env.GITEA_REPO }}/releases/${RELEASE_ID}/assets?name=${name}" \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"${f}"
|
||||||
|
done
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -31,3 +31,6 @@ cmd/amcs-server/__debug_*
|
|||||||
bin/
|
bin/
|
||||||
.cache/
|
.cache/
|
||||||
OB1/
|
OB1/
|
||||||
|
ui/node_modules/
|
||||||
|
ui/.svelte-kit/
|
||||||
|
internal/app/ui/dist/
|
||||||
|
|||||||
12
Dockerfile
12
Dockerfile
@@ -1,3 +1,14 @@
|
|||||||
|
FROM node:22-bookworm AS ui-builder
|
||||||
|
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
WORKDIR /src/ui
|
||||||
|
|
||||||
|
COPY ui/package.json ui/pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY ui/ ./
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
FROM golang:1.26.1-bookworm AS builder
|
FROM golang:1.26.1-bookworm AS builder
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
@@ -6,6 +17,7 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
COPY --from=ui-builder /src/internal/app/ui/dist ./internal/app/ui/dist
|
||||||
|
|
||||||
RUN set -eu; \
|
RUN set -eu; \
|
||||||
VERSION_TAG="$(git describe --tags --exact-match 2>/dev/null || echo dev)"; \
|
VERSION_TAG="$(git describe --tags --exact-match 2>/dev/null || echo dev)"; \
|
||||||
|
|||||||
52
Makefile
52
Makefile
@@ -3,25 +3,43 @@ GO_CACHE_DIR := $(CURDIR)/.cache/go-build
|
|||||||
SERVER_BIN := $(BIN_DIR)/amcs-server
|
SERVER_BIN := $(BIN_DIR)/amcs-server
|
||||||
CMD_SERVER := ./cmd/amcs-server
|
CMD_SERVER := ./cmd/amcs-server
|
||||||
BUILDINFO_PKG := git.warky.dev/wdevs/amcs/internal/buildinfo
|
BUILDINFO_PKG := git.warky.dev/wdevs/amcs/internal/buildinfo
|
||||||
|
UI_DIR := $(CURDIR)/ui
|
||||||
PATCH_INCREMENT ?= 1
|
PATCH_INCREMENT ?= 1
|
||||||
VERSION_TAG ?= $(shell git describe --tags --exact-match 2>/dev/null || echo dev)
|
VERSION_TAG ?= $(shell git describe --tags --exact-match 2>/dev/null || echo dev)
|
||||||
COMMIT_SHA ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
COMMIT_SHA ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||||
BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
RELSPEC ?= $(shell command -v relspec 2>/dev/null || echo $(HOME)/go/bin/relspec)
|
||||||
|
SCHEMA_FILES := $(sort $(wildcard schema/*.dbml))
|
||||||
|
MERGE_TARGET_TMP := $(CURDIR)/.cache/schema.merge-target.dbml
|
||||||
|
GENERATED_SCHEMA_MIGRATION := migrations/020_generated_schema.sql
|
||||||
|
PNPM ?= pnpm
|
||||||
LDFLAGS := -s -w \
|
LDFLAGS := -s -w \
|
||||||
-X $(BUILDINFO_PKG).Version=$(VERSION_TAG) \
|
-X $(BUILDINFO_PKG).Version=$(VERSION_TAG) \
|
||||||
-X $(BUILDINFO_PKG).TagName=$(VERSION_TAG) \
|
-X $(BUILDINFO_PKG).TagName=$(VERSION_TAG) \
|
||||||
-X $(BUILDINFO_PKG).Commit=$(COMMIT_SHA) \
|
-X $(BUILDINFO_PKG).Commit=$(COMMIT_SHA) \
|
||||||
-X $(BUILDINFO_PKG).BuildDate=$(BUILD_DATE)
|
-X $(BUILDINFO_PKG).BuildDate=$(BUILD_DATE)
|
||||||
|
|
||||||
.PHONY: all build clean migrate release-version test
|
.PHONY: all build clean migrate release-version test generate-migrations check-schema-drift build-cli ui-install ui-build ui-dev ui-check
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
build:
|
build: ui-build
|
||||||
@mkdir -p $(BIN_DIR)
|
@mkdir -p $(BIN_DIR)
|
||||||
go build -ldflags "$(LDFLAGS)" -o $(SERVER_BIN) $(CMD_SERVER)
|
go build -ldflags "$(LDFLAGS)" -o $(SERVER_BIN) $(CMD_SERVER)
|
||||||
|
|
||||||
test:
|
ui-install:
|
||||||
|
cd $(UI_DIR) && $(PNPM) install --frozen-lockfile
|
||||||
|
|
||||||
|
ui-build: ui-install
|
||||||
|
cd $(UI_DIR) && $(PNPM) run build
|
||||||
|
|
||||||
|
ui-dev: ui-install
|
||||||
|
cd $(UI_DIR) && $(PNPM) run dev
|
||||||
|
|
||||||
|
ui-check: ui-install
|
||||||
|
cd $(UI_DIR) && $(PNPM) run check
|
||||||
|
|
||||||
|
test: ui-check
|
||||||
@mkdir -p $(GO_CACHE_DIR)
|
@mkdir -p $(GO_CACHE_DIR)
|
||||||
GOCACHE=$(GO_CACHE_DIR) go test ./...
|
GOCACHE=$(GO_CACHE_DIR) go test ./...
|
||||||
|
|
||||||
@@ -50,3 +68,31 @@ migrate:
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf $(BIN_DIR)
|
rm -rf $(BIN_DIR)
|
||||||
|
|
||||||
|
generate-migrations:
|
||||||
|
@test -n "$(SCHEMA_FILES)" || (echo "No DBML schema files found in schema/" >&2; exit 1)
|
||||||
|
@command -v $(RELSPEC) >/dev/null 2>&1 || (echo "relspec not found; install git.warky.dev/wdevs/relspecgo/cmd/relspec@latest" >&2; exit 1)
|
||||||
|
@mkdir -p $(dir $(MERGE_TARGET_TMP))
|
||||||
|
@: > $(MERGE_TARGET_TMP)
|
||||||
|
@schema_list=$$(printf '%s\n' $(SCHEMA_FILES) | paste -sd, -); \
|
||||||
|
$(RELSPEC) merge --target dbml --target-path $(MERGE_TARGET_TMP) --source dbml --from-list "$$schema_list" --output pgsql --output-path $(GENERATED_SCHEMA_MIGRATION)
|
||||||
|
|
||||||
|
check-schema-drift:
|
||||||
|
@test -f $(GENERATED_SCHEMA_MIGRATION) || (echo "$(GENERATED_SCHEMA_MIGRATION) is missing; run make generate-migrations" >&2; exit 1)
|
||||||
|
@command -v $(RELSPEC) >/dev/null 2>&1 || (echo "relspec not found; install git.warky.dev/wdevs/relspecgo/cmd/relspec@latest" >&2; exit 1)
|
||||||
|
@mkdir -p $(dir $(MERGE_TARGET_TMP))
|
||||||
|
@tmpfile=$$(mktemp); \
|
||||||
|
: > $(MERGE_TARGET_TMP); \
|
||||||
|
schema_list=$$(printf '%s\n' $(SCHEMA_FILES) | paste -sd, -); \
|
||||||
|
$(RELSPEC) merge --target dbml --target-path $(MERGE_TARGET_TMP) --source dbml --from-list "$$schema_list" --output pgsql --output-path $$tmpfile; \
|
||||||
|
if ! cmp -s $$tmpfile $(GENERATED_SCHEMA_MIGRATION); then \
|
||||||
|
echo "Schema drift detected between schema/*.dbml and $(GENERATED_SCHEMA_MIGRATION)" >&2; \
|
||||||
|
diff -u $(GENERATED_SCHEMA_MIGRATION) $$tmpfile || true; \
|
||||||
|
rm -f $$tmpfile; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
rm -f $$tmpfile
|
||||||
|
|
||||||
|
build-cli:
|
||||||
|
@mkdir -p $(BIN_DIR)
|
||||||
|
go build -o $(BIN_DIR)/amcs-cli ./cmd/amcs-cli
|
||||||
|
|||||||
32
README.md
32
README.md
@@ -531,7 +531,37 @@ Run the SQL migrations against a local database with:
|
|||||||
|
|
||||||
`DATABASE_URL=postgres://... make migrate`
|
`DATABASE_URL=postgres://... make migrate`
|
||||||
|
|
||||||
LLM integration instructions are served at `/llm`.
|
### Backend + embedded UI build
|
||||||
|
|
||||||
|
The web UI now lives in the top-level `ui/` module and is embedded into the Go binary at build time with `go:embed`.
|
||||||
|
|
||||||
|
**Use `pnpm` for all UI work in this repo.**
|
||||||
|
|
||||||
|
- `make build` — runs the real UI build first, then compiles the Go server
|
||||||
|
- `make test` — runs `svelte-check` for the frontend and `go test ./...` for the backend
|
||||||
|
- `make ui-install` — installs frontend dependencies with `pnpm install --frozen-lockfile`
|
||||||
|
- `make ui-build` — builds only the frontend bundle
|
||||||
|
- `make ui-dev` — starts the Vite dev server with hot reload on `http://localhost:5173`
|
||||||
|
- `make ui-check` — runs the frontend type and Svelte checks
|
||||||
|
|
||||||
|
### Local UI workflow
|
||||||
|
|
||||||
|
For the normal production-style local flow:
|
||||||
|
|
||||||
|
1. Start the backend: `./scripts/run-local.sh configs/dev.yaml`
|
||||||
|
2. Open `http://localhost:8080`
|
||||||
|
|
||||||
|
For frontend iteration with hot reload and no Go rebuilds:
|
||||||
|
|
||||||
|
1. Start the backend once: `go run ./cmd/amcs-server --config configs/dev.yaml`
|
||||||
|
2. In another shell start the UI dev server: `make ui-dev`
|
||||||
|
3. Open `http://localhost:5173`
|
||||||
|
|
||||||
|
The Vite dev server proxies backend routes such as `/api/status`, `/llm`, `/healthz`, `/readyz`, `/files`, `/mcp`, and the OAuth endpoints back to the Go server on `http://127.0.0.1:8080` by default. Override that target with `AMCS_UI_BACKEND` if needed.
|
||||||
|
|
||||||
|
The root page (`/`) is now the Svelte frontend. It preserves the existing landing-page content and status information by fetching data from `GET /api/status`.
|
||||||
|
|
||||||
|
LLM integration instructions are still served at `/llm`.
|
||||||
|
|
||||||
## Containers
|
## Containers
|
||||||
|
|
||||||
|
|||||||
98
cmd/amcs-cli/cmd/call.go
Normal file
98
cmd/amcs-cli/cmd/call.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var argFlags []string
|
||||||
|
|
||||||
|
var callCmd = &cobra.Command{
|
||||||
|
Use: "call <tool>",
|
||||||
|
Short: "Call a remote AMCS tool",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
toolName := args[0]
|
||||||
|
toolArgs, err := parseArgs(argFlags)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := connectRemote(cmd.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = session.Close() }()
|
||||||
|
|
||||||
|
res, err := session.CallTool(cmd.Context(), &mcp.CallToolParams{Name: toolName, Arguments: toolArgs})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("call tool %q: %w", toolName, err)
|
||||||
|
}
|
||||||
|
return printOutput(res)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
callCmd.Flags().StringArrayVar(&argFlags, "arg", nil, "Tool argument in key=value format (repeatable)")
|
||||||
|
rootCmd.AddCommand(callCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseArgs(items []string) (map[string]any, error) {
|
||||||
|
result := make(map[string]any, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
key, value, ok := strings.Cut(item, "=")
|
||||||
|
if !ok || strings.TrimSpace(key) == "" {
|
||||||
|
return nil, fmt.Errorf("invalid --arg %q: want key=value", item)
|
||||||
|
}
|
||||||
|
result[key] = parseScalar(value)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseScalar(s string) any {
|
||||||
|
if s == "true" || s == "false" {
|
||||||
|
b, _ := strconv.ParseBool(s)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
if i, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
if f, err := strconv.ParseFloat(s, 64); err == nil && strings.ContainsAny(s, ".eE") {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
var v any
|
||||||
|
if err := json.Unmarshal([]byte(s), &v); err == nil {
|
||||||
|
switch v.(type) {
|
||||||
|
case map[string]any, []any, float64, bool, nil:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func printOutput(v any) error {
|
||||||
|
switch outputFlag {
|
||||||
|
case "yaml":
|
||||||
|
data, err := yaml.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal yaml: %w", err)
|
||||||
|
}
|
||||||
|
_, err = os.Stdout.Write(data)
|
||||||
|
return err
|
||||||
|
default:
|
||||||
|
data, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal json: %w", err)
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
_, err = os.Stdout.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
60
cmd/amcs-cli/cmd/config.go
Normal file
60
cmd/amcs-cli/cmd/config.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Server string `yaml:"server"`
|
||||||
|
Token string `yaml:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultConfigPath() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve home dir: %w", err)
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".config", "amcs", "config.yaml"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveConfigPath(path string) (string, error) {
|
||||||
|
if strings.TrimSpace(path) != "" {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
return defaultConfigPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfigFile(path string) (Config, error) {
|
||||||
|
var cfg Config
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
return cfg, fmt.Errorf("read config: %w", err)
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return cfg, fmt.Errorf("parse config: %w", err)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveConfigFile(path string, cfg Config) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return fmt.Errorf("create config dir: %w", err)
|
||||||
|
}
|
||||||
|
data, err := yaml.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal config: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
134
cmd/amcs-cli/cmd/root.go
Normal file
134
cmd/amcs-cli/cmd/root.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cfgFile string
|
||||||
|
serverFlag string
|
||||||
|
tokenFlag string
|
||||||
|
outputFlag string
|
||||||
|
cfg Config
|
||||||
|
)
|
||||||
|
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "amcs-cli",
|
||||||
|
Short: "CLI for connecting to a remote AMCS MCP server",
|
||||||
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
|
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return loadConfig()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func Execute() {
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "Path to config file")
|
||||||
|
rootCmd.PersistentFlags().StringVar(&serverFlag, "server", "", "AMCS server URL")
|
||||||
|
rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "AMCS bearer token")
|
||||||
|
rootCmd.PersistentFlags().StringVar(&outputFlag, "output", "json", "Output format: json or yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() error {
|
||||||
|
path, err := resolveConfigPath(cfgFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
loaded, err := loadConfigFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg = loaded
|
||||||
|
if v := strings.TrimSpace(os.Getenv("AMCS_URL")); v != "" {
|
||||||
|
cfg.Server = v
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("AMCS_TOKEN")); v != "" {
|
||||||
|
cfg.Token = v
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(serverFlag); v != "" {
|
||||||
|
cfg.Server = v
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(tokenFlag); v != "" {
|
||||||
|
cfg.Token = v
|
||||||
|
}
|
||||||
|
outputFlag = strings.ToLower(strings.TrimSpace(outputFlag))
|
||||||
|
if outputFlag != "json" && outputFlag != "yaml" {
|
||||||
|
return fmt.Errorf("invalid --output %q: must be json or yaml", outputFlag)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireServer() error {
|
||||||
|
if strings.TrimSpace(cfg.Server) == "" {
|
||||||
|
return fmt.Errorf("server URL is required; set --server, AMCS_URL, or config server")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func endpointURL() string {
|
||||||
|
base := strings.TrimRight(strings.TrimSpace(cfg.Server), "/")
|
||||||
|
if strings.HasSuffix(base, "/mcp") {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return base + "/mcp"
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHTTPClient() *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: 0,
|
||||||
|
Transport: &bearerTransport{
|
||||||
|
base: http.DefaultTransport,
|
||||||
|
token: cfg.Token,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type bearerTransport struct {
|
||||||
|
base http.RoundTripper
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *bearerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
base := t.base
|
||||||
|
if base == nil {
|
||||||
|
base = http.DefaultTransport
|
||||||
|
}
|
||||||
|
clone := req.Clone(req.Context())
|
||||||
|
if strings.TrimSpace(t.token) != "" {
|
||||||
|
clone.Header.Set("Authorization", "Bearer "+t.token)
|
||||||
|
}
|
||||||
|
return base.RoundTrip(clone)
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectRemote(ctx context.Context) (*mcp.ClientSession, error) {
|
||||||
|
if err := requireServer(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client := mcp.NewClient(&mcp.Implementation{Name: "amcs-cli", Version: "0.0.1"}, nil)
|
||||||
|
transport := &mcp.StreamableClientTransport{
|
||||||
|
Endpoint: endpointURL(),
|
||||||
|
HTTPClient: newHTTPClient(),
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
session, err := client.Connect(ctx, transport, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect to AMCS server: %w", err)
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
62
cmd/amcs-cli/cmd/stdio.go
Normal file
62
cmd/amcs-cli/cmd/stdio.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var stdioCmd = &cobra.Command{
|
||||||
|
Use: "stdio",
|
||||||
|
Short: "Run a stdio MCP bridge backed by a remote AMCS server",
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
ctx := cmd.Context()
|
||||||
|
remote, err := connectRemote(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = remote.Close() }()
|
||||||
|
|
||||||
|
tools, err := remote.ListTools(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load remote tools: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := mcp.NewServer(&mcp.Implementation{
|
||||||
|
Name: "amcs-cli",
|
||||||
|
Title: "AMCS CLI Bridge",
|
||||||
|
Version: "0.0.1",
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
for _, tool := range tools.Tools {
|
||||||
|
remoteTool := tool
|
||||||
|
server.AddTool(&mcp.Tool{
|
||||||
|
Name: remoteTool.Name,
|
||||||
|
Description: remoteTool.Description,
|
||||||
|
InputSchema: remoteTool.InputSchema,
|
||||||
|
OutputSchema: remoteTool.OutputSchema,
|
||||||
|
Annotations: remoteTool.Annotations,
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
return remote.CallTool(ctx, &mcp.CallToolParams{
|
||||||
|
Name: req.Params.Name,
|
||||||
|
Arguments: req.Params.Arguments,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := server.Connect(ctx, &mcp.StdioTransport{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("start stdio bridge: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = session.Close() }()
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(stdioCmd)
|
||||||
|
}
|
||||||
38
cmd/amcs-cli/cmd/tools.go
Normal file
38
cmd/amcs-cli/cmd/tools.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var toolsCmd = &cobra.Command{
|
||||||
|
Use: "tools",
|
||||||
|
Short: "List tools available on the remote AMCS server",
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
session, err := connectRemote(cmd.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = session.Close() }()
|
||||||
|
|
||||||
|
res, err := session.ListTools(cmd.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list tools: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "NAME\tDESCRIPTION")
|
||||||
|
for _, tool := range res.Tools {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\n", tool.Name, strings.TrimSpace(tool.Description))
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(toolsCmd)
|
||||||
|
}
|
||||||
7
cmd/amcs-cli/main.go
Normal file
7
cmd/amcs-cli/main.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "git.warky.dev/wdevs/amcs/cmd/amcs-cli/cmd"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cmd.Execute()
|
||||||
|
}
|
||||||
3
go.mod
3
go.mod
@@ -8,11 +8,13 @@ require (
|
|||||||
github.com/jackc/pgx/v5 v5.9.1
|
github.com/jackc/pgx/v5 v5.9.1
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1
|
github.com/modelcontextprotocol/go-sdk v1.4.1
|
||||||
github.com/pgvector/pgvector-go v0.3.0
|
github.com/pgvector/pgvector-go v0.3.0
|
||||||
|
github.com/spf13/cobra v1.10.2
|
||||||
golang.org/x/sync v0.17.0
|
golang.org/x/sync v0.17.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
@@ -20,6 +22,7 @@ require (
|
|||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/segmentio/asm v1.1.3 // indirect
|
github.com/segmentio/asm v1.1.3 // indirect
|
||||||
github.com/segmentio/encoding v0.5.4 // indirect
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
golang.org/x/oauth2 v0.34.0 // indirect
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
|
|||||||
9
go.sum
9
go.sum
@@ -1,5 +1,6 @@
|
|||||||
entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
|
entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
|
||||||
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
|
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
@@ -16,6 +17,8 @@ github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbc
|
|||||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
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/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
@@ -44,10 +47,15 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
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/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
@@ -73,6 +81,7 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
|||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
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=
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
|
|||||||
@@ -158,7 +158,9 @@ func Run(ctx context.Context, configPath string) error {
|
|||||||
|
|
||||||
func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *store.DB, provider ai.Provider, keyring *auth.Keyring, oauthRegistry *auth.OAuthRegistry, tokenStore *auth.TokenStore, authCodes *auth.AuthCodeStore, dynClients *auth.DynamicClientStore, activeProjects *session.ActiveProjects) (http.Handler, error) {
|
func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *store.DB, provider ai.Provider, keyring *auth.Keyring, oauthRegistry *auth.OAuthRegistry, tokenStore *auth.TokenStore, authCodes *auth.AuthCodeStore, dynClients *auth.DynamicClientStore, activeProjects *session.ActiveProjects) (http.Handler, error) {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
authMiddleware := auth.Middleware(cfg.Auth, keyring, oauthRegistry, tokenStore, logger)
|
accessTracker := auth.NewAccessTracker()
|
||||||
|
oauthEnabled := oauthRegistry != nil && tokenStore != nil
|
||||||
|
authMiddleware := auth.Middleware(cfg.Auth, keyring, oauthRegistry, tokenStore, accessTracker, logger)
|
||||||
filesTool := tools.NewFilesTool(db, activeProjects)
|
filesTool := tools.NewFilesTool(db, activeProjects)
|
||||||
metadataRetryer := tools.NewMetadataRetryer(context.Background(), db, provider, cfg.Capture, cfg.AI.Metadata.Timeout, activeProjects, logger)
|
metadataRetryer := tools.NewMetadataRetryer(context.Background(), db, provider, cfg.Capture, cfg.AI.Metadata.Timeout, activeProjects, logger)
|
||||||
|
|
||||||
@@ -198,7 +200,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
|||||||
mux.Handle(cfg.MCP.Path, authMiddleware(mcpHandler))
|
mux.Handle(cfg.MCP.Path, authMiddleware(mcpHandler))
|
||||||
mux.Handle("/files", authMiddleware(fileHandler(filesTool)))
|
mux.Handle("/files", authMiddleware(fileHandler(filesTool)))
|
||||||
mux.Handle("/files/{id}", authMiddleware(fileHandler(filesTool)))
|
mux.Handle("/files/{id}", authMiddleware(fileHandler(filesTool)))
|
||||||
if oauthRegistry != nil && tokenStore != nil {
|
if oauthEnabled {
|
||||||
mux.HandleFunc("/.well-known/oauth-authorization-server", oauthMetadataHandler())
|
mux.HandleFunc("/.well-known/oauth-authorization-server", oauthMetadataHandler())
|
||||||
mux.HandleFunc("/oauth-authorization-server", oauthMetadataHandler())
|
mux.HandleFunc("/oauth-authorization-server", oauthMetadataHandler())
|
||||||
mux.HandleFunc("/oauth/register", oauthRegisterHandler(dynClients, logger))
|
mux.HandleFunc("/oauth/register", oauthRegisterHandler(dynClients, logger))
|
||||||
@@ -210,6 +212,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
|||||||
mux.HandleFunc("/images/project.jpg", serveHomeImage)
|
mux.HandleFunc("/images/project.jpg", serveHomeImage)
|
||||||
mux.HandleFunc("/images/icon.png", serveIcon)
|
mux.HandleFunc("/images/icon.png", serveIcon)
|
||||||
mux.HandleFunc("/llm", serveLLMInstructions)
|
mux.HandleFunc("/llm", serveLLMInstructions)
|
||||||
|
mux.HandleFunc("/api/status", statusAPIHandler(info, accessTracker, oauthEnabled))
|
||||||
|
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
@@ -227,59 +230,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
|||||||
_, _ = w.Write([]byte("ready"))
|
_, _ = w.Write([]byte("ready"))
|
||||||
})
|
})
|
||||||
|
|
||||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/", homeHandler(info, accessTracker, oauthEnabled))
|
||||||
if r.URL.Path != "/" {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
||||||
w.Header().Set("Allow", "GET, HEAD")
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const homePage = `<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>AMCS</title>
|
|
||||||
<style>
|
|
||||||
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f7fb; color: #172033; }
|
|
||||||
main { max-width: 860px; margin: 48px auto; background: #fff; border-radius: 12px; box-shadow: 0 10px 28px rgba(23, 32, 51, 0.12); overflow: hidden; }
|
|
||||||
.content { padding: 28px; }
|
|
||||||
h1 { margin: 0 0 12px 0; font-size: 2rem; }
|
|
||||||
p { margin: 0; line-height: 1.5; color: #334155; }
|
|
||||||
.actions { margin-top: 18px; }
|
|
||||||
.link { display: inline-block; padding: 10px 14px; border-radius: 8px; background: #172033; color: #fff; text-decoration: none; font-weight: 600; }
|
|
||||||
.link:hover { background: #0f172a; }
|
|
||||||
img { display: block; width: 100%; height: auto; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<img src="/images/project.jpg" alt="Avelon Memory Crystal project image">
|
|
||||||
<div class="content">
|
|
||||||
<h1>Avelon Memory Crystal Server (AMCS)</h1>
|
|
||||||
<p>AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools.</p>
|
|
||||||
<div class="actions">
|
|
||||||
<a class="link" href="/llm">LLM Instructions</a>
|
|
||||||
<a class="link" href="/oauth-authorization-server">OAuth Authorization Server</a>
|
|
||||||
<a class="link" href="/healthz">Health Check</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
if r.Method == http.MethodHead {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = w.Write([]byte(homePage))
|
|
||||||
})
|
|
||||||
|
|
||||||
return observability.Chain(
|
return observability.Chain(
|
||||||
mux,
|
mux,
|
||||||
|
|||||||
140
internal/app/status.go
Normal file
140
internal/app/status.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/auth"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/buildinfo"
|
||||||
|
)
|
||||||
|
|
||||||
|
const connectedWindow = 10 * time.Minute
|
||||||
|
|
||||||
|
type statusAPIResponse struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
BuildDate string `json:"build_date"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
ConnectedCount int `json:"connected_count"`
|
||||||
|
TotalKnown int `json:"total_known"`
|
||||||
|
ConnectedWindow string `json:"connected_window"`
|
||||||
|
Entries []auth.AccessSnapshot `json:"entries"`
|
||||||
|
OAuthEnabled bool `json:"oauth_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusSnapshot(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool, now time.Time) statusAPIResponse {
|
||||||
|
entries := tracker.Snapshot()
|
||||||
|
return statusAPIResponse{
|
||||||
|
Title: "Avelon Memory Crystal Server (AMCS)",
|
||||||
|
Description: "AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools.",
|
||||||
|
Version: fallback(info.Version, "dev"),
|
||||||
|
BuildDate: fallback(info.BuildDate, "unknown"),
|
||||||
|
Commit: fallback(info.Commit, "unknown"),
|
||||||
|
ConnectedCount: tracker.ConnectedCount(now, connectedWindow),
|
||||||
|
TotalKnown: len(entries),
|
||||||
|
ConnectedWindow: "last 10 minutes",
|
||||||
|
Entries: entries,
|
||||||
|
OAuthEnabled: oauthEnabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallback(value, defaultValue string) string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusAPIHandler(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/status" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
w.Header().Set("Allow", "GET, HEAD")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.NewEncoder(w).Encode(statusSnapshot(info, tracker, oauthEnabled, time.Now()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func homeHandler(_ buildinfo.Info, _ *auth.AccessTracker, _ bool) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
w.Header().Set("Allow", "GET, HEAD")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestPath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
||||||
|
if requestPath == "." {
|
||||||
|
requestPath = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestPath != "" {
|
||||||
|
if serveUIAsset(w, r, requestPath) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serveUIIndex(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveUIAsset(w http.ResponseWriter, r *http.Request, name string) bool {
|
||||||
|
if uiDistFS == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.Contains(name, "..") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
file, err := uiDistFS.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := fs.ReadFile(uiDistFS, name)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
http.ServeContent(w, r, info.Name(), info.ModTime(), bytes.NewReader(data))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveUIIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if indexHTML == nil {
|
||||||
|
http.Error(w, "ui assets not built", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write(indexHTML)
|
||||||
|
}
|
||||||
133
internal/app/status_test.go
Normal file
133
internal/app/status_test.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/auth"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/buildinfo"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStatusSnapshotHidesOAuthLinkWhenDisabled(t *testing.T) {
|
||||||
|
tracker := auth.NewAccessTracker()
|
||||||
|
snapshot := statusSnapshot(buildinfo.Info{Version: "v1.2.3", BuildDate: "2026-04-04", Commit: "abc123"}, tracker, false, time.Date(2026, 4, 4, 12, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
if snapshot.OAuthEnabled {
|
||||||
|
t.Fatal("OAuthEnabled = true, want false")
|
||||||
|
}
|
||||||
|
if snapshot.ConnectedCount != 0 {
|
||||||
|
t.Fatalf("ConnectedCount = %d, want 0", snapshot.ConnectedCount)
|
||||||
|
}
|
||||||
|
if snapshot.Title == "" {
|
||||||
|
t.Fatal("Title = empty, want non-empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusSnapshotShowsTrackedAccess(t *testing.T) {
|
||||||
|
tracker := auth.NewAccessTracker()
|
||||||
|
now := time.Date(2026, 4, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
tracker.Record("client-a", "/files", "127.0.0.1:1234", "tester", now)
|
||||||
|
|
||||||
|
snapshot := statusSnapshot(buildinfo.Info{Version: "v1.2.3"}, tracker, true, now)
|
||||||
|
|
||||||
|
if !snapshot.OAuthEnabled {
|
||||||
|
t.Fatal("OAuthEnabled = false, want true")
|
||||||
|
}
|
||||||
|
if snapshot.ConnectedCount != 1 {
|
||||||
|
t.Fatalf("ConnectedCount = %d, want 1", snapshot.ConnectedCount)
|
||||||
|
}
|
||||||
|
if len(snapshot.Entries) != 1 {
|
||||||
|
t.Fatalf("len(Entries) = %d, want 1", len(snapshot.Entries))
|
||||||
|
}
|
||||||
|
if snapshot.Entries[0].KeyID != "client-a" || snapshot.Entries[0].LastPath != "/files" {
|
||||||
|
t.Fatalf("entry = %+v, want keyID client-a and path /files", snapshot.Entries[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusAPIHandlerReturnsJSON(t *testing.T) {
|
||||||
|
handler := statusAPIHandler(buildinfo.Info{Version: "v1"}, auth.NewAccessTracker(), true)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "application/json") {
|
||||||
|
t.Fatalf("content-type = %q, want application/json", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload statusAPIResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if payload.Version != "v1" {
|
||||||
|
t.Fatalf("version = %q, want %q", payload.Version, "v1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHomeHandlerAllowsHead(t *testing.T) {
|
||||||
|
handler := homeHandler(buildinfo.Info{Version: "v1"}, auth.NewAccessTracker(), false)
|
||||||
|
req := httptest.NewRequest(http.MethodHead, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if body := rec.Body.String(); body != "" {
|
||||||
|
t.Fatalf("body = %q, want empty for HEAD", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHomeHandlerServesIndex(t *testing.T) {
|
||||||
|
handler := homeHandler(buildinfo.Info{Version: "v1"}, auth.NewAccessTracker(), false)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "<div id=\"app\"></div>") {
|
||||||
|
t.Fatalf("body = %q, want embedded UI index", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiddlewareRecordsAuthenticatedAccess(t *testing.T) {
|
||||||
|
keyring, err := auth.NewKeyring([]config.APIKey{{ID: "client-a", Value: "secret"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewKeyring() error = %v", err)
|
||||||
|
}
|
||||||
|
tracker := auth.NewAccessTracker()
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
handler := auth.Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, tracker, logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/files", nil)
|
||||||
|
req.Header.Set("x-brain-key", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
snap := tracker.Snapshot()
|
||||||
|
if len(snap) != 1 {
|
||||||
|
t.Fatalf("len(snapshot) = %d, want 1", len(snap))
|
||||||
|
}
|
||||||
|
if snap[0].KeyID != "client-a" || snap[0].LastPath != "/files" {
|
||||||
|
t.Fatalf("snapshot[0] = %+v, want keyID client-a and path /files", snap[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
22
internal/app/ui_assets.go
Normal file
22
internal/app/ui_assets.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed ui/dist
|
||||||
|
uiFiles embed.FS
|
||||||
|
uiDistFS fs.FS
|
||||||
|
indexHTML []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
dist, err := fs.Sub(uiFiles, "ui/dist")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uiDistFS = dist
|
||||||
|
indexHTML, _ = fs.ReadFile(uiDistFS, "index.html")
|
||||||
|
}
|
||||||
81
internal/auth/access_tracker.go
Normal file
81
internal/auth/access_tracker.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccessSnapshot struct {
|
||||||
|
KeyID string
|
||||||
|
LastPath string
|
||||||
|
RemoteAddr string
|
||||||
|
UserAgent string
|
||||||
|
RequestCount int
|
||||||
|
LastAccessedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccessTracker struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
entries map[string]AccessSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAccessTracker() *AccessTracker {
|
||||||
|
return &AccessTracker{entries: make(map[string]AccessSnapshot)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AccessTracker) Record(keyID, path, remoteAddr, userAgent string, now time.Time) {
|
||||||
|
if t == nil || keyID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
entry := t.entries[keyID]
|
||||||
|
entry.KeyID = keyID
|
||||||
|
entry.LastPath = path
|
||||||
|
entry.RemoteAddr = remoteAddr
|
||||||
|
entry.UserAgent = userAgent
|
||||||
|
entry.LastAccessedAt = now.UTC()
|
||||||
|
entry.RequestCount++
|
||||||
|
t.entries[keyID] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AccessTracker) Snapshot() []AccessSnapshot {
|
||||||
|
if t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
items := make([]AccessSnapshot, 0, len(t.entries))
|
||||||
|
for _, entry := range t.entries {
|
||||||
|
items = append(items, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
return items[i].LastAccessedAt.After(items[j].LastAccessedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *AccessTracker) ConnectedCount(now time.Time, window time.Duration) int {
|
||||||
|
if t == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoff := now.UTC().Add(-window)
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for _, entry := range t.entries {
|
||||||
|
if !entry.LastAccessedAt.Before(cutoff) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
45
internal/auth/access_tracker_test.go
Normal file
45
internal/auth/access_tracker_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAccessTrackerRecordAndSnapshot(t *testing.T) {
|
||||||
|
tracker := NewAccessTracker()
|
||||||
|
older := time.Date(2026, 4, 4, 10, 0, 0, 0, time.UTC)
|
||||||
|
newer := older.Add(2 * time.Minute)
|
||||||
|
|
||||||
|
tracker.Record("client-a", "/files", "10.0.0.1:1234", "agent-a", older)
|
||||||
|
tracker.Record("client-b", "/mcp", "10.0.0.2:1234", "agent-b", newer)
|
||||||
|
tracker.Record("client-a", "/files/1", "10.0.0.1:1234", "agent-a2", newer.Add(30*time.Second))
|
||||||
|
|
||||||
|
snap := tracker.Snapshot()
|
||||||
|
if len(snap) != 2 {
|
||||||
|
t.Fatalf("len(snapshot) = %d, want 2", len(snap))
|
||||||
|
}
|
||||||
|
if snap[0].KeyID != "client-a" {
|
||||||
|
t.Fatalf("snapshot[0].KeyID = %q, want client-a", snap[0].KeyID)
|
||||||
|
}
|
||||||
|
if snap[0].RequestCount != 2 {
|
||||||
|
t.Fatalf("snapshot[0].RequestCount = %d, want 2", snap[0].RequestCount)
|
||||||
|
}
|
||||||
|
if snap[0].LastPath != "/files/1" {
|
||||||
|
t.Fatalf("snapshot[0].LastPath = %q, want /files/1", snap[0].LastPath)
|
||||||
|
}
|
||||||
|
if snap[0].UserAgent != "agent-a2" {
|
||||||
|
t.Fatalf("snapshot[0].UserAgent = %q, want agent-a2", snap[0].UserAgent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessTrackerConnectedCount(t *testing.T) {
|
||||||
|
tracker := NewAccessTracker()
|
||||||
|
now := time.Date(2026, 4, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
tracker.Record("recent", "/mcp", "", "", now.Add(-2*time.Minute))
|
||||||
|
tracker.Record("stale", "/mcp", "", "", now.Add(-11*time.Minute))
|
||||||
|
|
||||||
|
if got := tracker.ConnectedCount(now, 10*time.Minute); got != 1 {
|
||||||
|
t.Fatalf("ConnectedCount() = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ func TestMiddlewareAllowsHeaderAuthAndSetsContext(t *testing.T) {
|
|||||||
t.Fatalf("NewKeyring() error = %v", err)
|
t.Fatalf("NewKeyring() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
keyID, ok := KeyIDFromContext(r.Context())
|
keyID, ok := KeyIDFromContext(r.Context())
|
||||||
if !ok || keyID != "client-a" {
|
if !ok || keyID != "client-a" {
|
||||||
t.Fatalf("KeyIDFromContext() = (%q, %v), want (client-a, true)", keyID, ok)
|
t.Fatalf("KeyIDFromContext() = (%q, %v), want (client-a, true)", keyID, ok)
|
||||||
@@ -63,7 +63,7 @@ func TestMiddlewareAllowsBearerAuthAndSetsContext(t *testing.T) {
|
|||||||
t.Fatalf("NewKeyring() error = %v", err)
|
t.Fatalf("NewKeyring() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
keyID, ok := KeyIDFromContext(r.Context())
|
keyID, ok := KeyIDFromContext(r.Context())
|
||||||
if !ok || keyID != "client-a" {
|
if !ok || keyID != "client-a" {
|
||||||
t.Fatalf("KeyIDFromContext() = (%q, %v), want (client-a, true)", keyID, ok)
|
t.Fatalf("KeyIDFromContext() = (%q, %v), want (client-a, true)", keyID, ok)
|
||||||
@@ -90,7 +90,7 @@ func TestMiddlewarePrefersExplicitHeaderOverBearerAuth(t *testing.T) {
|
|||||||
t.Fatalf("NewKeyring() error = %v", err)
|
t.Fatalf("NewKeyring() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
keyID, ok := KeyIDFromContext(r.Context())
|
keyID, ok := KeyIDFromContext(r.Context())
|
||||||
if !ok || keyID != "client-a" {
|
if !ok || keyID != "client-a" {
|
||||||
t.Fatalf("KeyIDFromContext() = (%q, %v), want (client-a, true)", keyID, ok)
|
t.Fatalf("KeyIDFromContext() = (%q, %v), want (client-a, true)", keyID, ok)
|
||||||
@@ -119,7 +119,7 @@ func TestMiddlewareAllowsQueryParamWhenEnabled(t *testing.T) {
|
|||||||
HeaderName: "x-brain-key",
|
HeaderName: "x-brain-key",
|
||||||
QueryParam: "key",
|
QueryParam: "key",
|
||||||
AllowQueryParam: true,
|
AllowQueryParam: true,
|
||||||
}, keyring, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
}, keyring, nil, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ func TestMiddlewareRejectsMissingOrInvalidKey(t *testing.T) {
|
|||||||
t.Fatalf("NewKeyring() error = %v", err)
|
t.Fatalf("NewKeyring() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
t.Fatal("next handler should not be called")
|
t.Fatal("next handler should not be called")
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.warky.dev/wdevs/amcs/internal/config"
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
)
|
)
|
||||||
@@ -14,11 +15,16 @@ type contextKey string
|
|||||||
|
|
||||||
const keyIDContextKey contextKey = "auth.key_id"
|
const keyIDContextKey contextKey = "auth.key_id"
|
||||||
|
|
||||||
func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthRegistry, tokenStore *TokenStore, log *slog.Logger) func(http.Handler) http.Handler {
|
func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthRegistry, tokenStore *TokenStore, tracker *AccessTracker, log *slog.Logger) func(http.Handler) http.Handler {
|
||||||
headerName := cfg.HeaderName
|
headerName := cfg.HeaderName
|
||||||
if headerName == "" {
|
if headerName == "" {
|
||||||
headerName = "x-brain-key"
|
headerName = "x-brain-key"
|
||||||
}
|
}
|
||||||
|
recordAccess := func(r *http.Request, keyID string) {
|
||||||
|
if tracker != nil {
|
||||||
|
tracker.Record(keyID, r.URL.Path, r.RemoteAddr, r.UserAgent(), time.Now())
|
||||||
|
}
|
||||||
|
}
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// 1. Custom header → keyring only.
|
// 1. Custom header → keyring only.
|
||||||
@@ -30,6 +36,7 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
|
|||||||
http.Error(w, "invalid API key", http.StatusUnauthorized)
|
http.Error(w, "invalid API key", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
recordAccess(r, keyID)
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -39,12 +46,14 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
|
|||||||
if bearer := extractBearer(r); bearer != "" {
|
if bearer := extractBearer(r); bearer != "" {
|
||||||
if tokenStore != nil {
|
if tokenStore != nil {
|
||||||
if keyID, ok := tokenStore.Lookup(bearer); ok {
|
if keyID, ok := tokenStore.Lookup(bearer); ok {
|
||||||
|
recordAccess(r, keyID)
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if keyring != nil {
|
if keyring != nil {
|
||||||
if keyID, ok := keyring.Lookup(bearer); ok {
|
if keyID, ok := keyring.Lookup(bearer); ok {
|
||||||
|
recordAccess(r, keyID)
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -66,6 +75,7 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
|
|||||||
http.Error(w, "invalid OAuth client credentials", http.StatusUnauthorized)
|
http.Error(w, "invalid OAuth client credentials", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
recordAccess(r, keyID)
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -79,6 +89,7 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
|
|||||||
http.Error(w, "invalid API key", http.StatusUnauthorized)
|
http.Error(w, "invalid API key", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
recordAccess(r, keyID)
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func TestMiddlewareAllowsOAuthBasicAuthAndSetsContext(t *testing.T) {
|
|||||||
t.Fatalf("NewOAuthRegistry() error = %v", err)
|
t.Fatalf("NewOAuthRegistry() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := Middleware(config.AuthConfig{}, nil, oauthRegistry, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := Middleware(config.AuthConfig{}, nil, oauthRegistry, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
keyID, ok := KeyIDFromContext(r.Context())
|
keyID, ok := KeyIDFromContext(r.Context())
|
||||||
if !ok || keyID != "oauth-client" {
|
if !ok || keyID != "oauth-client" {
|
||||||
t.Fatalf("KeyIDFromContext() = (%q, %v), want (oauth-client, true)", keyID, ok)
|
t.Fatalf("KeyIDFromContext() = (%q, %v), want (oauth-client, true)", keyID, ok)
|
||||||
@@ -70,7 +70,7 @@ func TestMiddlewareRejectsOAuthMissingOrInvalidCredentials(t *testing.T) {
|
|||||||
t.Fatalf("NewOAuthRegistry() error = %v", err)
|
t.Fatalf("NewOAuthRegistry() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := Middleware(config.AuthConfig{}, nil, oauthRegistry, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := Middleware(config.AuthConfig{}, nil, oauthRegistry, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
t.Fatal("next handler should not be called")
|
t.Fatal("next handler should not be called")
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
3996
migrations/020_generated_schema.sql
Normal file
3996
migrations/020_generated_schema.sql
Normal file
File diff suppressed because it is too large
Load Diff
35
schema/README.md
Normal file
35
schema/README.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Schema workflow
|
||||||
|
|
||||||
|
The `schema/*.dbml` files are the database schema source of truth.
|
||||||
|
|
||||||
|
## Generate SQL migrations
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make generate-migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
This uses `relspec` to convert the DBML files into PostgreSQL SQL and writes the generated schema migration to:
|
||||||
|
|
||||||
|
- `migrations/020_generated_schema.sql`
|
||||||
|
|
||||||
|
## Check schema drift
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make check-schema-drift
|
||||||
|
```
|
||||||
|
|
||||||
|
This regenerates the SQL from `schema/*.dbml` and compares it with `migrations/020_generated_schema.sql`.
|
||||||
|
If the generated output differs, the command fails so CI can catch schema drift.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Update the DBML files in `schema/`
|
||||||
|
2. Run `make generate-migrations`
|
||||||
|
3. Review the generated SQL
|
||||||
|
4. Commit both the DBML changes and the generated migration
|
||||||
|
|
||||||
|
Existing handwritten migrations stay in place. Going forward, update the DBML first and regenerate the SQL from there.
|
||||||
44
schema/calendar.dbml
Normal file
44
schema/calendar.dbml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
Table family_members {
|
||||||
|
id uuid [pk, default: `gen_random_uuid()`]
|
||||||
|
name text [not null]
|
||||||
|
relationship text
|
||||||
|
birth_date date
|
||||||
|
notes text
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
}
|
||||||
|
|
||||||
|
Table activities {
|
||||||
|
id uuid [pk, default: `gen_random_uuid()`]
|
||||||
|
family_member_id uuid [ref: > family_members.id]
|
||||||
|
title text [not null]
|
||||||
|
activity_type text
|
||||||
|
day_of_week text
|
||||||
|
start_time time
|
||||||
|
end_time time
|
||||||
|
start_date date
|
||||||
|
end_date date
|
||||||
|
location text
|
||||||
|
notes text
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
day_of_week
|
||||||
|
family_member_id
|
||||||
|
(start_date, end_date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Table important_dates {
|
||||||
|
id uuid [pk, default: `gen_random_uuid()`]
|
||||||
|
family_member_id uuid [ref: > family_members.id]
|
||||||
|
title text [not null]
|
||||||
|
date_value date [not null]
|
||||||
|
recurring_yearly boolean [not null, default: false]
|
||||||
|
reminder_days_before int [not null, default: 7]
|
||||||
|
notes text
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
date_value
|
||||||
|
}
|
||||||
|
}
|
||||||
48
schema/core.dbml
Normal file
48
schema/core.dbml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
Table thoughts {
|
||||||
|
id bigserial [pk]
|
||||||
|
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||||
|
content text [not null]
|
||||||
|
metadata jsonb [default: `'{}'::jsonb`]
|
||||||
|
created_at timestamptz [default: `now()`]
|
||||||
|
updated_at timestamptz [default: `now()`]
|
||||||
|
project_id uuid [ref: > projects.guid]
|
||||||
|
archived_at timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
Table projects {
|
||||||
|
id bigserial [pk]
|
||||||
|
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||||
|
name text [unique, not null]
|
||||||
|
description text
|
||||||
|
created_at timestamptz [default: `now()`]
|
||||||
|
last_active_at timestamptz [default: `now()`]
|
||||||
|
}
|
||||||
|
|
||||||
|
Table thought_links {
|
||||||
|
from_id bigint [not null, ref: > thoughts.id]
|
||||||
|
to_id bigint [not null, ref: > thoughts.id]
|
||||||
|
relation text [not null]
|
||||||
|
created_at timestamptz [default: `now()`]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(from_id, to_id, relation) [pk]
|
||||||
|
from_id
|
||||||
|
to_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Table embeddings {
|
||||||
|
id bigserial [pk]
|
||||||
|
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||||
|
thought_id uuid [not null, ref: > thoughts.guid]
|
||||||
|
model text [not null]
|
||||||
|
dim int [not null]
|
||||||
|
embedding vector [not null]
|
||||||
|
created_at timestamptz [default: `now()`]
|
||||||
|
updated_at timestamptz [default: `now()`]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(thought_id, model) [unique]
|
||||||
|
thought_id
|
||||||
|
}
|
||||||
|
}
|
||||||
53
schema/crm.dbml
Normal file
53
schema/crm.dbml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
Table professional_contacts {
|
||||||
|
id uuid [pk, default: `gen_random_uuid()`]
|
||||||
|
name text [not null]
|
||||||
|
company text
|
||||||
|
title text
|
||||||
|
email text
|
||||||
|
phone text
|
||||||
|
linkedin_url text
|
||||||
|
how_we_met text
|
||||||
|
tags "text[]" [not null, default: `'{}'`]
|
||||||
|
notes text
|
||||||
|
last_contacted timestamptz
|
||||||
|
follow_up_date date
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
updated_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
last_contacted
|
||||||
|
follow_up_date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Table contact_interactions {
|
||||||
|
id uuid [pk, default: `gen_random_uuid()`]
|
||||||
|
contact_id uuid [not null, ref: > professional_contacts.id]
|
||||||
|
interaction_type text [not null]
|
||||||
|
occurred_at timestamptz [not null, default: `now()`]
|
||||||
|
summary text [not null]
|
||||||
|
follow_up_needed boolean [not null, default: false]
|
||||||
|
follow_up_notes text
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(contact_id, occurred_at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Table opportunities {
|
||||||
|
id uuid [pk, default: `gen_random_uuid()`]
|
||||||
|
contact_id uuid [ref: > professional_contacts.id]
|
||||||
|
title text [not null]
|
||||||
|
description text
|
||||||
|
stage text [not null, default: 'identified']
|
||||||
|
value "decimal(12,2)"
|
||||||
|
expected_close_date date
|
||||||
|
notes text
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
updated_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
stage
|
||||||
|
}
|
||||||
|
}
|
||||||
25
schema/files.dbml
Normal file
25
schema/files.dbml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
Table stored_files {
|
||||||
|
id bigserial [pk]
|
||||||
|
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||||
|
thought_id uuid [ref: > thoughts.guid]
|
||||||
|
project_id uuid [ref: > projects.guid]
|
||||||
|
name text [not null]
|
||||||
|
media_type text [not null]
|
||||||
|
kind text [not null, default: 'file']
|
||||||
|
encoding text [not null, default: 'base64']
|
||||||
|
size_bytes bigint [not null]
|
||||||
|
sha256 text [not null]
|
||||||
|
content bytea [not null]
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
updated_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
thought_id
|
||||||
|
project_id
|
||||||
|
sha256
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-file refs (for relspecgo merge)
|
||||||
|
Ref: stored_files.thought_id > thoughts.guid [delete: set null]
|
||||||
|
Ref: stored_files.project_id > projects.guid [delete: set null]
|
||||||
31
schema/household.dbml
Normal file
31
schema/household.dbml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
Table household_items {
|
||||||
|
id uuid [pk, default: `gen_random_uuid()`]
|
||||||
|
name text [not null]
|
||||||
|
category text
|
||||||
|
location text
|
||||||
|
details jsonb [not null, default: `'{}'`]
|
||||||
|
notes text
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
updated_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Table household_vendors {
|
||||||
|
id uuid [pk, default: `gen_random_uuid()`]
|
||||||
|
name text [not null]
|
||||||
|
service_type text
|
||||||
|
phone text
|
||||||
|
email text
|
||||||
|
website text
|
||||||
|
notes text
|
||||||
|
rating int
|
||||||
|
last_used date
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
service_type
|
||||||
|
}
|
||||||
|
}
|
||||||
30
schema/maintenance.dbml
Normal file
30
schema/maintenance.dbml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
Table maintenance_tasks {
|
||||||
|
id uuid [pk, default: `gen_random_uuid()`]
|
||||||
|
name text [not null]
|
||||||
|
category text
|
||||||
|
frequency_days int
|
||||||
|
last_completed timestamptz
|
||||||
|
next_due timestamptz
|
||||||
|
priority text [not null, default: 'medium']
|
||||||
|
notes text
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
updated_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
next_due
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Table maintenance_logs {
|
||||||
|
id uuid [pk, default: `gen_random_uuid()`]
|
||||||
|
task_id uuid [not null, ref: > maintenance_tasks.id]
|
||||||
|
completed_at timestamptz [not null, default: `now()`]
|
||||||
|
performed_by text
|
||||||
|
cost "decimal(10,2)"
|
||||||
|
notes text
|
||||||
|
next_action text
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(task_id, completed_at)
|
||||||
|
}
|
||||||
|
}
|
||||||
49
schema/meals.dbml
Normal file
49
schema/meals.dbml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
Table recipes {
|
||||||
|
id uuid [pk, default: `gen_random_uuid()`]
|
||||||
|
name text [not null]
|
||||||
|
cuisine text
|
||||||
|
prep_time_minutes int
|
||||||
|
cook_time_minutes int
|
||||||
|
servings int
|
||||||
|
ingredients jsonb [not null, default: `'[]'`]
|
||||||
|
instructions jsonb [not null, default: `'[]'`]
|
||||||
|
tags "text[]" [not null, default: `'{}'`]
|
||||||
|
rating int
|
||||||
|
notes text
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
updated_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
cuisine
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Table meal_plans {
|
||||||
|
id uuid [pk, default: `gen_random_uuid()`]
|
||||||
|
week_start date [not null]
|
||||||
|
day_of_week text [not null]
|
||||||
|
meal_type text [not null]
|
||||||
|
recipe_id uuid [ref: > recipes.id]
|
||||||
|
custom_meal text
|
||||||
|
servings int
|
||||||
|
notes text
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
week_start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Table shopping_lists {
|
||||||
|
id uuid [pk, default: `gen_random_uuid()`]
|
||||||
|
week_start date [unique, not null]
|
||||||
|
items jsonb [not null, default: `'[]'`]
|
||||||
|
notes text
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
updated_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
week_start
|
||||||
|
}
|
||||||
|
}
|
||||||
32
schema/meta.dbml
Normal file
32
schema/meta.dbml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
Table chat_histories {
|
||||||
|
id uuid [pk, default: `gen_random_uuid()`]
|
||||||
|
session_id text [not null]
|
||||||
|
title text
|
||||||
|
channel text
|
||||||
|
agent_id text
|
||||||
|
project_id uuid [ref: > projects.guid]
|
||||||
|
messages jsonb [not null, default: `'[]'`]
|
||||||
|
summary text
|
||||||
|
metadata jsonb [not null, default: `'{}'`]
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
updated_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
session_id
|
||||||
|
project_id
|
||||||
|
channel
|
||||||
|
agent_id
|
||||||
|
created_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Table tool_annotations {
|
||||||
|
id bigserial [pk]
|
||||||
|
tool_name text [unique, not null]
|
||||||
|
notes text [not null, default: '']
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
updated_at timestamptz [not null, default: `now()`]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-file refs (for relspecgo merge)
|
||||||
|
Ref: chat_histories.project_id > projects.guid [delete: set null]
|
||||||
46
schema/skills.dbml
Normal file
46
schema/skills.dbml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
Table agent_skills {
|
||||||
|
id uuid [pk, default: `gen_random_uuid()`]
|
||||||
|
name text [unique, not null]
|
||||||
|
description text [not null, default: '']
|
||||||
|
content text [not null]
|
||||||
|
tags "text[]" [not null, default: `'{}'`]
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
updated_at timestamptz [not null, default: `now()`]
|
||||||
|
}
|
||||||
|
|
||||||
|
Table agent_guardrails {
|
||||||
|
id uuid [pk, default: `gen_random_uuid()`]
|
||||||
|
name text [unique, not null]
|
||||||
|
description text [not null, default: '']
|
||||||
|
content text [not null]
|
||||||
|
severity text [not null, default: 'medium']
|
||||||
|
tags "text[]" [not null, default: `'{}'`]
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
updated_at timestamptz [not null, default: `now()`]
|
||||||
|
}
|
||||||
|
|
||||||
|
Table project_skills {
|
||||||
|
project_id uuid [not null, ref: > projects.guid]
|
||||||
|
skill_id uuid [not null, ref: > agent_skills.id]
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(project_id, skill_id) [pk]
|
||||||
|
project_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Table project_guardrails {
|
||||||
|
project_id uuid [not null, ref: > projects.guid]
|
||||||
|
guardrail_id uuid [not null, ref: > agent_guardrails.id]
|
||||||
|
created_at timestamptz [not null, default: `now()`]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(project_id, guardrail_id) [pk]
|
||||||
|
project_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-file refs (for relspecgo merge)
|
||||||
|
Ref: project_skills.project_id > projects.guid [delete: cascade]
|
||||||
|
Ref: project_guardrails.project_id > projects.guid [delete: cascade]
|
||||||
@@ -2,4 +2,11 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
go run ./cmd/amcs-server --config "${1:-configs/dev.yaml}"
|
CONFIG_PATH="${1:-configs/dev.yaml}"
|
||||||
|
|
||||||
|
if [[ ! -f internal/app/ui/dist/index.html ]]; then
|
||||||
|
echo "UI build not found; building frontend first..."
|
||||||
|
make ui-build
|
||||||
|
fi
|
||||||
|
|
||||||
|
go run ./cmd/amcs-server --config "$CONFIG_PATH"
|
||||||
|
|||||||
16
ui/index.html
Normal file
16
ui/index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AMCS</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools."
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body class="bg-slate-950">
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
ui/package.json
Normal file
23
ui/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "amcs-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"packageManager": "pnpm@10.33.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
|
"@tailwindcss/vite": "^4.1.4",
|
||||||
|
"@types/node": "^24.5.2",
|
||||||
|
"svelte": "^5.28.2",
|
||||||
|
"svelte-check": "^4.1.6",
|
||||||
|
"tailwindcss": "^4.1.4",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "^6.3.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
1291
ui/pnpm-lock.yaml
generated
Normal file
1291
ui/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
257
ui/src/App.svelte
Normal file
257
ui/src/App.svelte
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
type AccessEntry = {
|
||||||
|
key_id: string;
|
||||||
|
last_accessed_at: string;
|
||||||
|
last_path: string;
|
||||||
|
request_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StatusResponse = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
build_date: string;
|
||||||
|
commit: string;
|
||||||
|
connected_count: number;
|
||||||
|
total_known: number;
|
||||||
|
connected_window: string;
|
||||||
|
oauth_enabled: boolean;
|
||||||
|
entries: AccessEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let data: StatusResponse | null = null;
|
||||||
|
let loading = true;
|
||||||
|
let error = "";
|
||||||
|
|
||||||
|
const quickLinks = [
|
||||||
|
{ href: "/llm", label: "LLM Instructions" },
|
||||||
|
{ href: "/healthz", label: "Health Check" },
|
||||||
|
{ href: "/readyz", label: "Readiness Check" },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function loadStatus() {
|
||||||
|
loading = true;
|
||||||
|
error = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/status");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Status request failed with ${response.status}`);
|
||||||
|
}
|
||||||
|
data = (await response.json()) as StatusResponse;
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : "Failed to load status";
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(loadStatus);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>AMCS</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-slate-950 text-slate-100">
|
||||||
|
<main
|
||||||
|
class="mx-auto flex min-h-screen max-w-7xl flex-col px-4 py-6 sm:px-6 lg:px-8"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
class="overflow-hidden rounded-3xl border border-white/10 bg-slate-900 shadow-2xl shadow-slate-950/40"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/project.jpg"
|
||||||
|
alt="Avelon Memory Crystal"
|
||||||
|
class="h-64 w-full object-cover object-center sm:h-80"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="grid gap-8 p-6 sm:p-8 lg:grid-cols-[1.6fr_1fr] lg:p-10">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 rounded-full border border-cyan-400/20 bg-cyan-400/10 px-3 py-1 text-sm font-medium text-cyan-200"
|
||||||
|
>
|
||||||
|
<span class="h-2 w-2 rounded-full bg-emerald-400"></span>
|
||||||
|
Avalon Memory Crystal Server
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
class="text-3xl font-semibold tracking-tight text-white sm:text-4xl"
|
||||||
|
>
|
||||||
|
Avelon Memory Crystal Server (AMCS)
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
class="mt-3 max-w-3xl text-base leading-7 text-slate-300 sm:text-lg"
|
||||||
|
>
|
||||||
|
{data?.description ??
|
||||||
|
"AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
{#each quickLinks as link}
|
||||||
|
<a
|
||||||
|
class="inline-flex items-center justify-center rounded-xl border border-cyan-300/20 bg-cyan-400/10 px-4 py-2 text-sm font-semibold text-cyan-100 transition hover:border-cyan-300/40 hover:bg-cyan-400/20"
|
||||||
|
href={link.href}>{link.label}</a
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
{#if data?.oauth_enabled}
|
||||||
|
<a
|
||||||
|
class="inline-flex items-center justify-center rounded-xl border border-violet-300/20 bg-violet-400/10 px-4 py-2 text-sm font-semibold text-violet-100 transition hover:border-violet-300/40 hover:bg-violet-400/20"
|
||||||
|
href="/oauth-authorization-server">OAuth Authorization Server</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">
|
||||||
|
Connected users
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-3xl font-semibold text-white">
|
||||||
|
{data?.connected_count ?? "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">
|
||||||
|
Known principals
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-3xl font-semibold text-white">
|
||||||
|
{data?.total_known ?? "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">
|
||||||
|
Version
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 break-all text-2xl font-semibold text-white">
|
||||||
|
{data?.version ?? "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
class="space-y-4 rounded-2xl border border-white/10 bg-slate-950/50 p-5"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-white">Build details</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-400">The same status info.</p>
|
||||||
|
</div>
|
||||||
|
<dl class="space-y-3 text-sm text-slate-300">
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-500">Build date</dt>
|
||||||
|
<dd class="mt-1 font-medium text-white">
|
||||||
|
{data?.build_date ?? "unknown"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-500">Commit</dt>
|
||||||
|
<dd
|
||||||
|
class="mt-1 break-all rounded-lg bg-white/5 px-3 py-2 font-mono text-xs text-cyan-100"
|
||||||
|
>
|
||||||
|
{data?.commit ?? "unknown"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-500">Connected window</dt>
|
||||||
|
<dd class="mt-1 font-medium text-white">
|
||||||
|
{data?.connected_window ?? "last 10 minutes"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="mt-6 rounded-3xl border border-white/10 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/20 sm:p-8"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-semibold text-white">Recent access</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-400">
|
||||||
|
Authenticated principals AMCS has seen recently.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-slate-200 transition hover:bg-white/10"
|
||||||
|
on:click={loadStatus}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div
|
||||||
|
class="mt-6 rounded-2xl border border-dashed border-white/10 bg-slate-950/40 px-4 py-10 text-center text-slate-400"
|
||||||
|
>
|
||||||
|
Loading status…
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div
|
||||||
|
class="mt-6 rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-6 text-sm text-rose-100"
|
||||||
|
>
|
||||||
|
<p class="font-semibold">Couldn’t load the status snapshot.</p>
|
||||||
|
<p class="mt-1 text-rose-100/80">{error}</p>
|
||||||
|
</div>
|
||||||
|
{:else if data && data.entries.length === 0}
|
||||||
|
<div
|
||||||
|
class="mt-6 rounded-2xl border border-dashed border-white/10 bg-slate-950/40 px-4 py-10 text-center text-slate-400"
|
||||||
|
>
|
||||||
|
No authenticated access recorded yet.
|
||||||
|
</div>
|
||||||
|
{:else if data}
|
||||||
|
<div class="mt-6 overflow-hidden rounded-2xl border border-white/10">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table
|
||||||
|
class="min-w-full divide-y divide-white/10 text-left text-sm text-slate-300"
|
||||||
|
>
|
||||||
|
<thead
|
||||||
|
class="bg-white/5 text-xs uppercase tracking-[0.2em] text-slate-500"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 font-medium">Principal</th>
|
||||||
|
<th class="px-4 py-3 font-medium">Last accessed</th>
|
||||||
|
<th class="px-4 py-3 font-medium">Last path</th>
|
||||||
|
<th class="px-4 py-3 font-medium">Requests</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-white/5 bg-slate-950/30">
|
||||||
|
{#each data.entries as entry}
|
||||||
|
<tr class="hover:bg-white/[0.03]">
|
||||||
|
<td class="px-4 py-3 align-top"
|
||||||
|
><code
|
||||||
|
class="rounded bg-white/5 px-2 py-1 font-mono text-xs text-cyan-100"
|
||||||
|
>{entry.key_id}</code
|
||||||
|
></td
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3 align-top text-slate-200"
|
||||||
|
>{formatDate(entry.last_accessed_at)}</td
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3 align-top"
|
||||||
|
><code class="text-slate-100">{entry.last_path}</code></td
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3 align-top font-semibold text-white"
|
||||||
|
>{entry.request_count}</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
16
ui/src/app.css
Normal file
16
ui/src/app.css
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: Inter, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
9
ui/src/main.ts
Normal file
9
ui/src/main.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import './app.css';
|
||||||
|
import App from './App.svelte';
|
||||||
|
import { mount } from 'svelte';
|
||||||
|
|
||||||
|
const app = mount(App, {
|
||||||
|
target: document.getElementById('app')!
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
5
ui/svelte.config.js
Normal file
5
ui/svelte.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
compilerOptions: {
|
||||||
|
dev: process.env.NODE_ENV !== 'production'
|
||||||
|
}
|
||||||
|
};
|
||||||
15
ui/tsconfig.json
Normal file
15
ui/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.node.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"strict": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": false,
|
||||||
|
"types": ["svelte", "node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "vite.config.ts"]
|
||||||
|
}
|
||||||
8
ui/tsconfig.node.json
Normal file
8
ui/tsconfig.node.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
}
|
||||||
|
}
|
||||||
31
ui/vite.config.ts
Normal file
31
ui/vite.config.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
|
const backendTarget = process.env.AMCS_UI_BACKEND ?? 'http://127.0.0.1:8080';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': backendTarget,
|
||||||
|
'/healthz': backendTarget,
|
||||||
|
'/readyz': backendTarget,
|
||||||
|
'/llm': backendTarget,
|
||||||
|
'/images': backendTarget,
|
||||||
|
'/favicon.ico': backendTarget,
|
||||||
|
'/mcp': backendTarget,
|
||||||
|
'/files': backendTarget,
|
||||||
|
'/oauth-authorization-server': backendTarget,
|
||||||
|
'/authorize': backendTarget,
|
||||||
|
'/oauth': backendTarget,
|
||||||
|
'/.well-known': backendTarget
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: '../internal/app/ui/dist',
|
||||||
|
emptyOutDir: true
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user