11 Commits

Author SHA1 Message Date
28f7dc199e Merge branch 'feat/issue-17-svelte-ui'
Some checks failed
CI / build-and-test (push) Failing after -30m29s
2026-04-05 11:09:59 +02:00
Jack O'Neill
73eb852361 build: merge main make targets into ui branch 2026-04-05 11:04:55 +02:00
Jack O'Neill
a42274a770 build: switch ui workflow to pnpm 2026-04-05 10:59:05 +02:00
f0d9c4dc09 style(ui): improve code formatting and consistency in App.svelte 2026-04-05 10:52:25 +02:00
Jack O'Neill
4bf1c1fe60 fix: use svelte 5 mount api 2026-04-05 10:47:28 +02:00
Jack O'Neill
6c6f4022a0 feat: add embedded svelte frontend 2026-04-05 09:40:38 +02:00
1328b3cc94 Merge pull request 'ci: Gitea Actions — CI build/test + cross-platform release on tags' (#22) from feat/gitea-actions into main
All checks were successful
CI / build-and-test (push) Successful in -30m49s
Reviewed-on: #22
2026-04-04 14:37:08 +00:00
SGC
87a62c0d6c ci: add Gitea Actions — CI on push/PR, release on version tags
Some checks failed
CI / build-and-test (pull_request) Failing after -31m8s
CI / build-and-test (push) Successful in -28m7s
- .gitea/workflows/ci.yml: build + test on every push and PR
- .gitea/workflows/release.yml: cross-platform release binaries on v*.*.* tags
  - Builds amcs-server and amcs-cli for linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64
  - Embeds version, commit, and build date via ldflags
  - Creates Gitea Release with all binaries and checksums.txt attached
2026-04-04 16:28:19 +02:00
3e09dc0ac6 Merge pull request 'feat: amcs-cli — MCP tool CLI and stdio bridge' (#21) from feat/amcs-cli into main 2026-04-04 14:25:04 +00:00
SGC
b59e02aebe feat: add amcs-cli — MCP tool CLI and stdio bridge
- cmd/amcs-cli: new CLI tool for human/AI MCP tool access
- amcs-cli tools: list all tools from remote MCP server
- amcs-cli call <tool> --arg k=v: invoke a tool, print JSON/YAML result
- amcs-cli stdio: stdio→HTTP MCP bridge for AI clients
- Config: ~/.config/amcs/config.yaml, AMCS_URL/AMCS_TOKEN env vars, --server/--token flags
- Token never logged in errors
- Makefile: add build-cli target

Closes #18
2026-04-04 16:14:25 +02:00
4713110e32 Merge pull request 'feat: DBML schema files + relspecgo migration generation' (#20) from feat/dbml-schema-relspecgo into main
Reviewed-on: #20
Reviewed-by: Warky <hein.puth@gmail.com>
2026-04-04 13:37:26 +00:00
29 changed files with 2481 additions and 145 deletions

41
.gitea/workflows/ci.yml Normal file
View 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

View 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
View File

@@ -31,3 +31,6 @@ cmd/amcs-server/__debug_*
bin/ bin/
.cache/ .cache/
OB1/ OB1/
ui/node_modules/
ui/.svelte-kit/
internal/app/ui/dist/

View File

@@ -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)"; \

View File

@@ -3,6 +3,7 @@ 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)
@@ -11,21 +12,34 @@ RELSPEC ?= $(shell command -v relspec 2>/dev/null || echo $(HOME)/go/bin/relspec
SCHEMA_FILES := $(sort $(wildcard schema/*.dbml)) SCHEMA_FILES := $(sort $(wildcard schema/*.dbml))
MERGE_TARGET_TMP := $(CURDIR)/.cache/schema.merge-target.dbml MERGE_TARGET_TMP := $(CURDIR)/.cache/schema.merge-target.dbml
GENERATED_SCHEMA_MIGRATION := migrations/020_generated_schema.sql 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 generate-migrations check-schema-drift .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 ./...
@@ -78,3 +92,7 @@ check-schema-drift:
exit 1; \ exit 1; \
fi; \ fi; \
rm -f $$tmpfile rm -f $$tmpfile
build-cli:
@mkdir -p $(BIN_DIR)
go build -o $(BIN_DIR)/amcs-cli ./cmd/amcs-cli

View File

@@ -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
View 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
}
}

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
package main
import "git.warky.dev/wdevs/amcs/cmd/amcs-cli/cmd"
func main() {
cmd.Execute()
}

3
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -212,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)

View File

@@ -1,9 +1,11 @@
package app package app
import ( import (
"fmt" "bytes"
"html" "encoding/json"
"io/fs"
"net/http" "net/http"
"path"
"strings" "strings"
"time" "time"
@@ -13,131 +15,33 @@ import (
const connectedWindow = 10 * time.Minute const connectedWindow = 10 * time.Minute
type statusPageData struct { type statusAPIResponse struct {
Version string Title string `json:"title"`
BuildDate string Description string `json:"description"`
Commit string Version string `json:"version"`
ConnectedCount int BuildDate string `json:"build_date"`
TotalKnown int Commit string `json:"commit"`
Entries []auth.AccessSnapshot ConnectedCount int `json:"connected_count"`
OAuthEnabled bool TotalKnown int `json:"total_known"`
ConnectedWindow string `json:"connected_window"`
Entries []auth.AccessSnapshot `json:"entries"`
OAuthEnabled bool `json:"oauth_enabled"`
} }
func renderHomePage(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool, now time.Time) string { func statusSnapshot(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool, now time.Time) statusAPIResponse {
entries := tracker.Snapshot() entries := tracker.Snapshot()
data := statusPageData{ return statusAPIResponse{
Version: fallback(info.Version, "dev"), Title: "Avelon Memory Crystal Server (AMCS)",
BuildDate: fallback(info.BuildDate, "unknown"), Description: "AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools.",
Commit: fallback(info.Commit, "unknown"), Version: fallback(info.Version, "dev"),
ConnectedCount: tracker.ConnectedCount(now, connectedWindow), BuildDate: fallback(info.BuildDate, "unknown"),
TotalKnown: len(entries), Commit: fallback(info.Commit, "unknown"),
Entries: entries, ConnectedCount: tracker.ConnectedCount(now, connectedWindow),
OAuthEnabled: oauthEnabled, TotalKnown: len(entries),
ConnectedWindow: "last 10 minutes",
Entries: entries,
OAuthEnabled: oauthEnabled,
} }
var b strings.Builder
b.WriteString(`<!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: 980px; 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, h2 { margin: 0 0 12px 0; }
p { margin: 0; line-height: 1.5; color: #334155; }
.actions { margin-top: 18px; display: flex; flex-wrap: wrap; gap: 10px; }
.link { display: inline-block; padding: 10px 14px; border-radius: 8px; background: #172033; color: #fff; text-decoration: none; font-weight: 600; }
.link:hover { background: #0f172a; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-top: 24px; }
.card { background: #eef2ff; border-radius: 10px; padding: 16px; }
.label { display: block; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.04em; color: #475569; }
.value { display: block; margin-top: 6px; font-size: 1.4rem; font-weight: 700; color: #0f172a; }
.meta { margin-top: 28px; color: #475569; font-size: 0.95rem; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid #e2e8f0; vertical-align: top; }
th { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.04em; color: #475569; }
.empty { margin-top: 16px; color: #64748b; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
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="/healthz">Health Check</a>
<a class="link" href="/readyz">Readiness Check</a>`)
if data.OAuthEnabled {
b.WriteString(`
<a class="link" href="/oauth-authorization-server">OAuth Authorization Server</a>`)
}
b.WriteString(`
</div>
<div class="stats">
<div class="card">
<span class="label">Connected users</span>
<span class="value">` + fmt.Sprintf("%d", data.ConnectedCount) + `</span>
</div>
<div class="card">
<span class="label">Known principals</span>
<span class="value">` + fmt.Sprintf("%d", data.TotalKnown) + `</span>
</div>
<div class="card">
<span class="label">Version</span>
<span class="value">` + html.EscapeString(data.Version) + `</span>
</div>
</div>
<div class="meta">
<strong>Build date:</strong> ` + html.EscapeString(data.BuildDate) + ` &nbsp;•&nbsp;
<strong>Commit:</strong> <code>` + html.EscapeString(data.Commit) + `</code> &nbsp;•&nbsp;
<strong>Connected window:</strong> last 10 minutes
</div>
<h2 style="margin-top: 28px;">Recent access</h2>`)
if len(data.Entries) == 0 {
b.WriteString(`
<p class="empty">No authenticated access recorded yet.</p>`)
} else {
b.WriteString(`
<table>
<thead>
<tr>
<th>Principal</th>
<th>Last accessed</th>
<th>Last path</th>
<th>Requests</th>
</tr>
</thead>
<tbody>`)
for _, entry := range data.Entries {
b.WriteString(`
<tr>
<td><code>` + html.EscapeString(entry.KeyID) + `</code></td>
<td>` + html.EscapeString(entry.LastAccessedAt.UTC().Format(time.RFC3339)) + `</td>
<td>` + html.EscapeString(entry.LastPath) + `</td>
<td>` + fmt.Sprintf("%d", entry.RequestCount) + `</td>
</tr>`)
}
b.WriteString(`
</tbody>
</table>`)
}
b.WriteString(`
</div>
</main>
</body>
</html>`)
return b.String()
} }
func fallback(value, defaultValue string) string { func fallback(value, defaultValue string) string {
@@ -147,25 +51,90 @@ func fallback(value, defaultValue string) string {
return value return value
} }
func homeHandler(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool) http.HandlerFunc { func statusAPIHandler(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" { if r.URL.Path != "/api/status" {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
if r.Method != http.MethodGet && r.Method != http.MethodHead { if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD") w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead { if r.Method == http.MethodHead {
return return
} }
_, _ = w.Write([]byte(renderHomePage(info, tracker, oauthEnabled, time.Now()))) _ = 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)
}

View File

@@ -1,6 +1,7 @@
package app package app
import ( import (
"encoding/json"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -14,29 +15,62 @@ import (
"git.warky.dev/wdevs/amcs/internal/config" "git.warky.dev/wdevs/amcs/internal/config"
) )
func TestRenderHomePageHidesOAuthLinkWhenDisabled(t *testing.T) { func TestStatusSnapshotHidesOAuthLinkWhenDisabled(t *testing.T) {
tracker := auth.NewAccessTracker() tracker := auth.NewAccessTracker()
page := renderHomePage(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)) 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 strings.Contains(page, "/oauth-authorization-server") { if snapshot.OAuthEnabled {
t.Fatal("page unexpectedly contains OAuth link") t.Fatal("OAuthEnabled = true, want false")
} }
if !strings.Contains(page, "Connected users") { if snapshot.ConnectedCount != 0 {
t.Fatal("page missing Connected users stat") t.Fatalf("ConnectedCount = %d, want 0", snapshot.ConnectedCount)
}
if snapshot.Title == "" {
t.Fatal("Title = empty, want non-empty")
} }
} }
func TestRenderHomePageShowsTrackedAccess(t *testing.T) { func TestStatusSnapshotShowsTrackedAccess(t *testing.T) {
tracker := auth.NewAccessTracker() tracker := auth.NewAccessTracker()
now := time.Date(2026, 4, 4, 12, 0, 0, 0, time.UTC) now := time.Date(2026, 4, 4, 12, 0, 0, 0, time.UTC)
tracker.Record("client-a", "/files", "127.0.0.1:1234", "tester", now) tracker.Record("client-a", "/files", "127.0.0.1:1234", "tester", now)
page := renderHomePage(buildinfo.Info{Version: "v1.2.3"}, tracker, true, now) snapshot := statusSnapshot(buildinfo.Info{Version: "v1.2.3"}, tracker, true, now)
for _, needle := range []string{"client-a", "/files", "1</span>", "/oauth-authorization-server"} { if !snapshot.OAuthEnabled {
if !strings.Contains(page, needle) { t.Fatal("OAuthEnabled = false, want true")
t.Fatalf("page missing %q", needle) }
} 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")
} }
} }
@@ -55,6 +89,21 @@ func TestHomeHandlerAllowsHead(t *testing.T) {
} }
} }
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) { func TestMiddlewareRecordsAuthenticatedAccess(t *testing.T) {
keyring, err := auth.NewKeyring([]config.APIKey{{ID: "client-a", Value: "secret"}}) keyring, err := auth.NewKeyring([]config.APIKey{{ID: "client-a", Value: "secret"}})
if err != nil { if err != nil {

22
internal/app/ui_assets.go Normal file
View 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")
}

View File

@@ -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
View 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
View 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

File diff suppressed because it is too large Load Diff

257
ui/src/App.svelte Normal file
View 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">Couldnt 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
export default {
compilerOptions: {
dev: process.env.NODE_ENV !== 'production'
}
};

15
ui/tsconfig.json Normal file
View 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
View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
}
}

31
ui/vite.config.ts Normal file
View 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
}
});