feat(rhema-code): add the colors setting with four palettes

The stored `colors` key names a palette — rhema (the default,
Jadeite), aquamarine, black-and-white, or dracula — and the
Appearance page of /settings cycles it live, like the theme and
icon toggles beside it. Each palette carries a dark and a light
appearance under the existing `theme` key; dracula's light side is
derived, the official palette ships only dark.

The appearance decision moves off token comparison: Theme now
answers is_dark() from the ground/text luminance, so palettes
beyond the original two get the right per-profile fallback tones.
This commit is contained in:
vhaudiquet committed 2026-10-17 04:28:31 +08:00
commit 81da26b955
159 files changed
+50628

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
# rhema-claw: the resident forge-watching daemon.
# Build from the repo root (the context is the repo):
# docker build -f deploy/docker/claw.Dockerfile .
# The builder tag tracks rust-toolchain.toml: the pinned channel is what
# every commit is tested against. The full image (not -slim) carries the C
# toolchain rusqlite's bundled SQLite build needs.
FROM rust:1.98.1 AS builder
WORKDIR /build
# Manifests first: the registry cache this populates survives source edits.
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
COPY crates/rhema-acp/Cargo.toml crates/rhema-acp/Cargo.toml
COPY crates/rhema-agent-tools/Cargo.toml crates/rhema-agent-tools/Cargo.toml
COPY crates/rhema-autonomous-agent/Cargo.toml crates/rhema-autonomous-agent/Cargo.toml
COPY crates/rhema-claw/Cargo.toml crates/rhema-claw/Cargo.toml
COPY crates/rhema-code/Cargo.toml crates/rhema-code/Cargo.toml
COPY crates/rhema-coding-agent/Cargo.toml crates/rhema-coding-agent/Cargo.toml
COPY crates/rhema-config/Cargo.toml crates/rhema-config/Cargo.toml
COPY crates/rhema-core/Cargo.toml crates/rhema-core/Cargo.toml
COPY crates/rhema-identity/Cargo.toml crates/rhema-identity/Cargo.toml
COPY crates/rhema-memory/Cargo.toml crates/rhema-memory/Cargo.toml
COPY crates/rhema-provider/Cargo.toml crates/rhema-provider/Cargo.toml
COPY crates/rhema-provider-anthropic/Cargo.toml crates/rhema-provider-anthropic/Cargo.toml
COPY crates/rhema-provider-copilot/Cargo.toml crates/rhema-provider-copilot/Cargo.toml
COPY crates/rhema-provider-google/Cargo.toml crates/rhema-provider-google/Cargo.toml
COPY crates/rhema-provider-llama-cpp/Cargo.toml crates/rhema-provider-llama-cpp/Cargo.toml
COPY crates/rhema-provider-openai-compatible/Cargo.toml crates/rhema-provider-openai-compatible/Cargo.toml
COPY crates/rhema-provider-openrouter/Cargo.toml crates/rhema-provider-openrouter/Cargo.toml
COPY crates/rhema-rsd/Cargo.toml crates/rhema-rsd/Cargo.toml
COPY integrations/rhema-calendar-integration/Cargo.toml integrations/rhema-calendar-integration/Cargo.toml
COPY integrations/rhema-forge-integration/Cargo.toml integrations/rhema-forge-integration/Cargo.toml
# Resolution reads the vendored crossterm (the root manifest path-patches
# to it) even though the image builds the daemon, which does not link it.
COPY vendor/ vendor/
# Cargo validates explicit target
# paths at manifest-parse time, so the fetch needs stub files at every
# declared target path: src/ for every member, plus examples/tests/benches
# as declared (default path from the target name, or the explicit `path`).
# The loop drives off the crates/*/ and integrations/*/ globs because
# neither glob's */src matches anything until the stub exists — an
# unmatched glob would hand mkdir a literal path. The real sources land
# below. (The awk program must stay on one line: a Dockerfile line not
# ending in a backslash ends the RUN.)
RUN for crate in crates/*/ integrations/*/; do \
mkdir -p "$crate/src"; \
touch "$crate/src/lib.rs" "$crate/src/main.rs"; \
awk -F'"' '/^\[/ { d = ""; p = 0; if ($0 == "[[example]]") { d = "examples" } else if ($0 == "[[test]]") { d = "tests" } else if ($0 == "[[bench]]") { d = "benches" }; next } d == "" { next } /^name = / && p == 0 { print d "/" $2 ".rs" } /^path = / { p = 1; print $2 }' "$crate/Cargo.toml" \
| while IFS= read -r target; do \
mkdir -p "$crate$(dirname "$target")"; \
touch "$crate$target"; \
done; \
done \
&& cargo fetch
# Sources. target/ and .git never land here: the repo-root .dockerignore
# keeps them out of the context.
COPY . .
RUN cargo build --release -p rhema-claw
FROM debian:stable-slim
# bash and git are load-bearing, not conveniences: the coding agent shells
# out via `bash -c` and clones/fetches in its per-thread workspaces.
RUN apt-get update \
&& apt-get install -y --no-install-recommends bash git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Numeric USER: Kubernetes cannot verify runAsNonRoot against a name.
RUN useradd --uid 1000 --user-group --create-home claw \
&& install -d -o 1000 -g 1000 /config /data
COPY --from=builder /build/target/release/rhema-claw /usr/local/bin/rhema-claw
# The chart passes --config /config/claw.yaml (the ConfigMap mount) and
# mounts the state PVC at /data; these defaults serve a bare `docker run`.
ENV XDG_CONFIG_HOME=/config \
XDG_STATE_HOME=/data/state
VOLUME /data
EXPOSE 7799
USER 1000:1000
ENTRYPOINT ["/usr/local/bin/rhema-claw"]
+40
View File
@@ -0,0 +1,40 @@
# rhema-claw-ui: the web UI's static bundle behind nginx.
# Build from the repo root (the context is the repo):
# docker build -f deploy/docker/ui.Dockerfile -t rhema-claw-ui .
# The build: the Nuxt app generates a fully static bundle (ssr is off —
# the UI is a client of the claw's API, nothing server-side to run).
FROM node:22-alpine AS build
WORKDIR /web
COPY web/claw/package.json web/claw/package-lock.json ./
# The lock file pins the tree; ci is the reproducible install.
RUN npm ci
COPY web/claw ./
RUN npm run build
# The runtime: nginx serves the bundle with an SPA fallback — the app is
# one page, but a browser hitting a stale deep link must land on it, not
# on a 404. Index is served no-cache (a new deploy must not be masked by
# the browser), the hashed _nuxt assets are forever-cacheable.
FROM nginx:alpine
COPY <<'NGINX' /etc/nginx/conf.d/default.conf
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /_nuxt/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
}
NGINX
COPY --from=build /web/.output/public /usr/share/nginx/html
# The UI is stateless static files: any number of replicas, no PVC, and
# the claw's single-node invariant does not apply here.
EXPOSE 80
+11
View File
@@ -0,0 +1,11 @@
# Patterns to ignore when building the package.
.DS_Store
.git/
.gitignore
.nojekyll
*.swp
*.tmp
*.orig
*.rej
*.tgz
vault
+7
View File
@@ -0,0 +1,7 @@
apiVersion: v2
name: rhema-claw
description: "The resident forge-watching agent daemon: forge watches and a
chat HTTP surface over one SQLite store."
type: application
version: 0.1.0
appVersion: "0.1.0"
@@ -0,0 +1,31 @@
rhema-claw {{ .Chart.AppVersion }} deployed as {{ include "rhema-claw.fullname" . }}.
The daemon is single-node by design: one SQLite store, one dispatcher
(specs/rhema-framework-v1.md §7) — do not scale the Deployment.
1. Talk to it (port-forward; the surface speaks OpenAI chat-completions too):
kubectl port-forward svc/{{ include "rhema-claw.fullname" . }} {{ .Values.service.port }}:{{ .Values.service.port }}
curl http://127.0.0.1:{{ .Values.service.port }}/healthz
2. Config: values `config` renders into ConfigMap {{ include "rhema-claw.fullname" . }}
(claw.yaml, mounted at /config/claw.yaml). db and workspace_root are pinned
under /data; edit them through values, not the ConfigMap.
{{- if not (or .Values.secret.existingSecret .Values.secret.stringData) }}
3. No credentials Secret is configured — set values secret.stringData
(e.g. ANTHROPIC_API_KEY, GITHUB_TOKEN) or secret.existingSecret, or the
daemon refuses to start when a watch or dispatch needs the key.
{{- else }}
3. Credentials: injected from the Secret into the environment.
{{- end }}
4. Token: /v1 routes are protected by the token set in values; /healthz
stays open either way. The render refuses an empty token — the daemon
binds all interfaces in-cluster and will not serve unguarded.
{{- if not .Values.persistence.enabled }}
WARNING: persistence.enabled=false — the SQLite store and workspaces live in
an emptyDir and are lost when the pod restarts.
{{- end }}
@@ -0,0 +1,49 @@
{{/* Chart-wide names and labels. */}}
{{- define "rhema-claw.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "rhema-claw.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{- define "rhema-claw.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | quote }}
{{- end }}
{{- define "rhema-claw.labels" -}}
helm.sh/chart: {{ include "rhema-claw.chart" . }}
{{ include "rhema-claw.selectorLabels" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{- define "rhema-claw.selectorLabels" -}}
app.kubernetes.io/name: {{ include "rhema-claw.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- define "rhema-claw.imageTag" -}}
{{- default .Chart.AppVersion .Values.image.tag }}
{{- end }}
{{/*
The single-node invariant, enforced at render time: one daemon owns every
session in one SQLite store, with one dispatcher — scaling past one replica
would corrupt the store, not share the load (specs/rhema-framework-v1.md §7).
*/}}
{{- define "rhema-claw.assertSingleReplica" -}}
{{- if ne (int .Values.replicaCount) 1 }}
{{- fail (printf "replicaCount must be 1, got %v: rhema-claw is single-node by design (one SQLite store, one dispatcher; specs/rhema-framework-v1.md §7)" .Values.replicaCount) }}
{{- end }}
{{- end }}
@@ -0,0 +1,20 @@
{{- $cfg := deepCopy (.Values.config | default dict) }}
{{- /* The state volume is fixed at /data and the Service needs the process
bound to all interfaces, so these three win over whatever values say. */ -}}
{{- $_ := set $cfg "db" "/data/claw.db" }}
{{- $_ := set $cfg "workspace_root" "/data/workspaces" }}
{{- $_ := set $cfg "listen" (printf "0.0.0.0:%v" .Values.service.port) }}
{{- if .Values.token }}
{{- $_ := set $cfg "token" .Values.token }}
{{- else }}
{{- $_ := unset $cfg "token" }}
{{- end }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "rhema-claw.fullname" . }}
labels:
{{- include "rhema-claw.labels" . | nindent 4 }}
data:
claw.yaml: |
{{ toYaml $cfg | indent 4 }}
@@ -0,0 +1,99 @@
{{- include "rhema-claw.assertSingleReplica" . }}
{{- /* The daemon binds 0.0.0.0 in-cluster (the Service needs every
interface) and refuses to serve a non-loopback surface without a
bearer token — fail the render here, before the pod does. */ -}}
{{- if not .Values.token }}
{{- fail "token is required: the daemon binds all interfaces in-cluster, and it refuses to serve a non-loopback surface without a bearer token (set `token` in values)" }}
{{- end }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "rhema-claw.fullname" . }}
labels:
{{- include "rhema-claw.labels" . | nindent 4 }}
spec:
replicas: 1
selector:
matchLabels:
{{- include "rhema-claw.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: server
template:
metadata:
labels:
{{- include "rhema-claw.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: server
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
# The image runs as uid 1000; fsGroup makes the PVC writable by it.
fsGroup: 1000
containers:
- name: claw
image: "{{ .Values.image.repository }}:{{ include "rhema-claw.imageTag" . }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
args:
- --config
- /config/claw.yaml
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 2
periodSeconds: 5
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
{{- if or .Values.secret.existingSecret .Values.secret.stringData }}
envFrom:
- secretRef:
name: {{ .Values.secret.existingSecret | default (include "rhema-claw.fullname" .) }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: config
mountPath: /config
readOnly: true
- name: data
mountPath: /data
volumes:
- name: config
configMap:
name: {{ include "rhema-claw.fullname" . }}
- name: data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "rhema-claw.fullname" . }}
{{- else }}
emptyDir: {}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
@@ -0,0 +1,68 @@
{{- if .Values.ingress.enabled }}
{{- if not .Values.ingress.host }}
{{- fail "ingress.host is required when ingress.enabled: the claw is single-user and bearer-gated, and an unnamed host has no TLS story" }}
{{- end }}
{{- if and .Values.ingress.tls.enabled (not .Values.ingress.tls.secretName) }}
{{- fail "ingress.tls.secretName is required when ingress.tls.enabled: bearer tokens cross this ingress in the clear without TLS" }}
{{- end }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "rhema-claw.fullname" . }}
labels:
{{- include "rhema-claw.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
{{- if .Values.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.ingress.host | quote }}
secretName: {{ .Values.ingress.tls.secretName }}
{{- end }}
rules:
- host: {{ .Values.ingress.host | quote }}
http:
paths:
{{- if .Values.ui.enabled }}
# One address, two services: the browser gets the UI on /,
# the daemon answers /v1 and /healthz (longest prefix wins),
# and the phone app points at the same host. One origin, so
# no CORS anywhere; the bearer token is the auth.
- path: /v1
pathType: Prefix
backend:
service:
name: {{ include "rhema-claw.fullname" . }}
port:
name: http
- path: /healthz
pathType: Prefix
backend:
service:
name: {{ include "rhema-claw.fullname" . }}
port:
name: http
- path: /
pathType: Prefix
backend:
service:
name: {{ include "rhema-claw.fullname" . }}-ui
port:
name: http
{{- else }}
# No UI in the release: the daemon owns the whole host.
- path: /
pathType: Prefix
backend:
service:
name: {{ include "rhema-claw.fullname" . }}
port:
name: http
{{- end }}
{{- end }}
+17
View File
@@ -0,0 +1,17 @@
{{- if .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "rhema-claw.fullname" . }}
labels:
{{- include "rhema-claw.labels" . | nindent 4 }}
spec:
accessModes:
- ReadWriteOnce
{{- with .Values.persistence.storageClass }}
storageClassName: {{ . | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- end }}
@@ -0,0 +1,10 @@
{{- if and (not .Values.secret.existingSecret) .Values.secret.stringData }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "rhema-claw.fullname" . }}
labels:
{{- include "rhema-claw.labels" . | nindent 4 }}
stringData:
{{- toYaml .Values.secret.stringData | nindent 2 }}
{{- end }}
@@ -0,0 +1,18 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "rhema-claw.fullname" . }}
labels:
{{- include "rhema-claw.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
selector:
{{- include "rhema-claw.selectorLabels" . | nindent 4 }}
# The UI pods share the name/instance labels; without this the
# daemon's service load-balances chat traffic to nginx.
app.kubernetes.io/component: server
@@ -0,0 +1,45 @@
{{- if .Values.ui.enabled }}
{{- if not .Values.ui.image.repository }}
{{- fail "ui.image.repository is required when ui.enabled: the UI image is built from deploy/docker/ui.Dockerfile" }}
{{- end }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "rhema-claw.fullname" . }}-ui
labels:
{{- include "rhema-claw.labels" . | nindent 4 }}
app.kubernetes.io/component: ui
spec:
# Stateless static files: scaling the UI is safe. The single-node
# invariant belongs to the daemon, not to nginx.
replicas: {{ .Values.ui.replicas | default 1 }}
selector:
matchLabels:
{{- include "rhema-claw.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: ui
template:
metadata:
labels:
{{- include "rhema-claw.labels" . | nindent 8 }}
app.kubernetes.io/component: ui
spec:
containers:
- name: ui
image: "{{ .Values.ui.image.repository }}:{{ default .Chart.AppVersion .Values.ui.image.tag }}"
imagePullPolicy: {{ .Values.ui.image.pullPolicy }}
ports:
- name: http
containerPort: 80
# The shell answering is the whole contract: the bundle is
# static, the API calls happen browser-side against /v1.
livenessProbe:
httpGet:
path: /
port: http
readinessProbe:
httpGet:
path: /
port: http
resources:
{{- toYaml .Values.ui.resources | nindent 12 }}
{{- end }}
@@ -0,0 +1,17 @@
{{- if .Values.ui.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "rhema-claw.fullname" . }}-ui
labels:
{{- include "rhema-claw.labels" . | nindent 4 }}
app.kubernetes.io/component: ui
spec:
selector:
{{- include "rhema-claw.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: ui
ports:
- name: http
port: 80
targetPort: http
{{- end }}
+93
View File
@@ -0,0 +1,93 @@
# rhema-claw is single-node by design: one daemon owns every session in one
# SQLite store, with one dispatcher (specs/rhema-framework-v1.md §7). The
# templates fail the render when this is anything but 1 — it is an
# invariant, not a starting point.
replicaCount: 1
image:
repository: rhema-claw
# Defaults to Chart appVersion when empty.
tag: ""
pullPolicy: IfNotPresent
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
# Bearer token for the /v1 routes, rendered into claw.yaml. Required:
# the daemon binds all interfaces in-cluster (the Service needs it) and
# refuses to serve a non-loopback surface without a token, so the render
# fails when this is empty. /healthz stays open either way.
token: ""
# The claw.yaml content. db, workspace_root, and listen are pinned by the
# templates (the PVC is fixed at /data and the container must bind all
# interfaces for the Service); whatever these say here is overridden.
config:
poll_interval_secs: 60
# Seed watches: imported once, while the watch table is still empty.
# Example:
# watches:
# - forge: github
# repo: acme/widgets
watches: []
service:
type: ClusterIP
port: 7799
ingress:
# Off by default: the claw is single-user and bearer-gated, and bearer
# tokens must not cross the public internet in the clear. When enabled,
# a host is required (the render refuses without one), TLS is strongly
# recommended, and the whole surface — chat, /v1, /healthz — is one
# origin. A UI on the same host's / path composes: put the UI's
# ingress first, or route /v1 here from its own host.
enabled: false
# Required when enabled; the render refuses an ingress without one.
host: ""
# The ingress controller's class (nginx, traefik, ...); empty means
# the cluster default.
className: ""
annotations: {}
tls:
enabled: false
# The cert-manager (or manually provisioned) secret carrying the
# certificate; required when tls.enabled.
secretName: ""
ui:
# The web UI on the same host as the daemon: the ingress routes /
# here and /v1 (+ /healthz) to the daemon, so one address serves the
# browser and the phone app alike, with no CORS anywhere. Stateless
# static files — replicas may scale freely.
enabled: true
image:
# Built by deploy/docker/ui.Dockerfile; published by the ui job in
# the publish workflow.
repository: git.vhaudiquet.fr/vhaudiquet/rhema-claw-ui
# Defaults to the chart's appVersion when empty.
tag: ""
pullPolicy: IfNotPresent
resources: {}
persistence:
enabled: true
storageClass: ""
size: 5Gi
# Provider and forge credentials reach the daemon as env vars (the
# provider keys and GITHUB_TOKEN/GITEA_TOKEN/GITLAB_TOKEN fallbacks).
# One mechanism: either name an existingSecret, or list key/value pairs
# in stringData and the chart creates the Secret.
secret:
existingSecret: ""
stringData: {}
# stringData:
# ANTHROPIC_API_KEY: sk-ant-...
# GITHUB_TOKEN: ghp_...
resources: {}
nodeSelector: {}
tolerations: []
affinity: {}