Add API tokens, CLI server API and shared core crate

- Personal API tokens (full/read scope, expiry, revoke) and CLI password login
- Cargo workspace: codecraft-core (types, providers, permission rules) shared by
  server and the upcoming terminal client; server re-exports the old paths
- CLI server API: /cli/config, /cli/skill, /cli/tool (server MCP tools),
  /llm/stream model gateway with budgets and usage per user
- Theme/language preferences mirrored to the server; personal memory endpoint
- Statistics: provider and mode tables stacked; CLI usage labelled
- cli/: work in progress (terminal client is being rewritten as a local TUI)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 23:18:50 +01:00
1 parent fca1c41087
commit aa1f8cf016
40 files changed
+2454 -814

No files matched your search

+5 -1
View File
@@ -14,6 +14,7 @@ import { Images } from "./components/Images";
import { Jobs } from "./components/Jobs";
import { Spinner, useTitleActivity } from "./components/Activity";
import { DialogHost } from "./dialogs";
import { startPrefsSync } from "./prefsSync";
// The file manager (editor, git) is loaded on demand to keep the chat small.
const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
@@ -31,7 +32,10 @@ export function App() {
try {
const i = await get<Info>("/info");
setState(() => ({ me: i.user }));
if (i.user) await loadSessions();
if (i.user) {
await loadSessions();
void startPrefsSync();
}
setInfo(i);
} catch (e: any) {
setError(e.message);
+8 -10
View File
@@ -253,15 +253,13 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
<h3>{t("stats.byModel")}</h3>
<AggTable rows={data.by_model} keyName="model" label={t("stats.model")} currency={cur} />
</div>
<div className="two-col">
<div className="panel">
<h3>{t("stats.byProvider")}</h3>
<AggTable rows={data.by_provider} keyName="provider" label={t("stats.provider")} currency={cur} />
</div>
<div className="panel">
<h3>{t("stats.byAgent")}</h3>
<AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} />
</div>
<div className="panel">
<h3>{t("stats.byProvider")}</h3>
<AggTable rows={data.by_provider} keyName="provider" label={t("stats.provider")} currency={cur} />
</div>
<div className="panel">
<h3>{t("stats.byAgent")}</h3>
<AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} />
</div>
{isAdmin && !userFilter && data.by_user.length > 1 && (
<div className="panel">
@@ -271,7 +269,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
)}
<div className="panel">
<h3>{t("stats.topSessions")}</h3>
<AggTable rows={data.top_sessions} keyName="title" label={t("stats.session")} currency={cur} link={(r) => navigate(`/s/${r.session_id}`)} />
<AggTable rows={data.top_sessions} keyName="title" label={t("stats.session")} currency={cur} link={(r) => !r.session_id.startsWith("cli:") && navigate(`/s/${r.session_id}`)} />
</div>
</>
)}
+2
View File
@@ -3,6 +3,7 @@ import { post } from "../../api";
import { useT } from "../../i18n";
import { useStore } from "../../store";
import { GitAccount } from "./GitAccount";
import { ApiTokens } from "./ApiTokens";
export function Account() {
const t = useT();
@@ -46,6 +47,7 @@ export function Account() {
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div>
</div>
<GitAccount />
<ApiTokens />
</>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { useEffect, useState } from "react";
import { del, get, post } from "../../api";
import { fmtRelative, useT } from "../../i18n";
import { confirmDialog, toast } from "../../dialogs";
import { Icon } from "../Icon";
interface TokenInfo {
id: string;
name: string;
prefix: string;
scope: "full" | "read";
created_at: number;
last_used?: number | null;
expires_at?: number | null;
}
/** Personal API tokens for the CLI and scripts. */
export function ApiTokens() {
const t = useT();
const [list, setList] = useState<TokenInfo[]>([]);
const [name, setName] = useState("");
const [scope, setScope] = useState<"full" | "read">("full");
const [days, setDays] = useState("");
const [created, setCreated] = useState<string>();
const reload = () => get<TokenInfo[]>("/account/tokens").then(setList).catch((e) => toast(e.message));
useEffect(() => {
void reload();
}, []);
const create = async () => {
try {
const r = await post<{ token: string }>("/account/tokens", { name: name.trim(), scope, days: days ? Number(days) : undefined });
setCreated(r.token);
setName("");
void reload();
} catch (e: any) {
toast(e.message);
}
};
return (
<div className="panel">
<h3>
<Icon name="lock" /> {t("tokens.title")}
</h3>
<div className="hint" style={{ marginBottom: 10 }}>
{t("tokens.hint")}
</div>
{created && (
<div className="notice ok token-created">
<div>{t("tokens.copyNow")}</div>
<div className="row" style={{ gap: 6, marginTop: 6 }}>
<code className="token-value">{created}</code>
<button
className="small"
onClick={() => navigator.clipboard.writeText(created).then(() => toast(t("tokens.copied"), "success"))}
>
<Icon name="copy" /> {t("tokens.copy")}
</button>
</div>
<pre className="mono token-example">{`codecraft login ${location.origin}`}</pre>
</div>
)}
{list.length > 0 && (
<table className="grid">
<thead>
<tr>
<th>{t("tokens.name")}</th>
<th>{t("tokens.scope")}</th>
<th>{t("tokens.lastUsed")}</th>
<th>{t("tokens.expires")}</th>
<th />
</tr>
</thead>
<tbody>
{list.map((k) => (
<tr key={k.id}>
<td>
{k.name} <code className="muted">{k.prefix}…</code>
</td>
<td>{t(k.scope === "read" ? "tokens.scopeRead" : "tokens.scopeFull")}</td>
<td>{k.last_used ? fmtRelative(k.last_used) : "–"}</td>
<td>{k.expires_at ? new Date(k.expires_at).toLocaleDateString() : t("tokens.never")}</td>
<td>
<button
className="small ghost danger"
aria-label={t("tokens.revoke")}
title={t("tokens.revoke")}
onClick={async () => {
if (await confirmDialog({ message: t("tokens.revokeConfirm", { name: k.name }), danger: true })) {
await del(`/account/tokens/${k.id}`).catch((e) => toast(e.message));
void reload();
}
}}
>
<Icon name="trash" />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<div className="row" style={{ gap: 6, marginTop: 10, flexWrap: "wrap" }}>
<input style={{ flex: 1, minWidth: 160 }} placeholder={t("tokens.namePlaceholder")} value={name} onChange={(e) => setName(e.target.value)} />
<select value={scope} onChange={(e) => setScope(e.target.value as "full" | "read")}>
<option value="full">{t("tokens.scopeFull")}</option>
<option value="read">{t("tokens.scopeRead")}</option>
</select>
<select value={days} onChange={(e) => setDays(e.target.value)}>
<option value="">{t("tokens.noExpiry")}</option>
<option value="30">30 {t("tokens.days")}</option>
<option value="90">90 {t("tokens.days")}</option>
<option value="365">365 {t("tokens.days")}</option>
</select>
<button className="primary" disabled={!name.trim()} onClick={create}>
<Icon name="plus" /> {t("tokens.create")}
</button>
</div>
</div>
);
}
+18
View File
@@ -370,6 +370,24 @@ export const de: Messages = {
"voice.stop": "Diktat beenden",
"voice.denied": "Mikrofonzugriff verweigert – in den Browser-Einstellungen erlauben.",
"voice.error": "Spracherkennung fehlgeschlagen",
"tokens.title": "API-Tokens",
"tokens.hint": "Für den Kommandozeilen-Client (codecraft) und Skripte: als „Authorization: Bearer <token>“ mitschicken. Nur-Lesen-Tokens können nur lesen (keine Prompts, keine Änderungen).",
"tokens.copyNow": "Token jetzt kopieren – er wird nur einmal angezeigt:",
"tokens.copy": "Kopieren",
"tokens.copied": "Kopiert",
"tokens.name": "Name",
"tokens.scope": "Rechte",
"tokens.lastUsed": "Zuletzt benutzt",
"tokens.expires": "Läuft ab",
"tokens.never": "nie",
"tokens.revoke": "Widerrufen",
"tokens.revokeConfirm": "Token „{name}“ widerrufen? Programme, die ihn nutzen, verlieren sofort den Zugriff.",
"tokens.namePlaceholder": "Name, z. B. Laptop-CLI",
"tokens.scopeFull": "Voller Zugriff",
"tokens.scopeRead": "Nur lesen",
"tokens.noExpiry": "Kein Ablauf",
"tokens.days": "Tage",
"tokens.create": "Token erstellen",
"new.title": "Neue Session",
"new.directory": "Projektverzeichnis",
"new.up": "Hoch",
+18
View File
@@ -369,6 +369,24 @@ export const en = {
"voice.stop": "Stop dictation",
"voice.denied": "Microphone access was denied – allow it in the browser settings.",
"voice.error": "Speech recognition failed",
"tokens.title": "API tokens",
"tokens.hint": "For the command line client (codecraft) and scripts: send as “Authorization: Bearer <token>”. Read-only tokens can only read (no prompts, no changes).",
"tokens.copyNow": "Copy the token now – it is shown only once:",
"tokens.copy": "Copy",
"tokens.copied": "Copied",
"tokens.name": "Name",
"tokens.scope": "Rights",
"tokens.lastUsed": "Last used",
"tokens.expires": "Expires",
"tokens.never": "never",
"tokens.revoke": "Revoke",
"tokens.revokeConfirm": "Revoke the token “{name}”? Programs using it lose access immediately.",
"tokens.namePlaceholder": "Name, e.g. laptop CLI",
"tokens.scopeFull": "Full access",
"tokens.scopeRead": "Read only",
"tokens.noExpiry": "No expiry",
"tokens.days": "days",
"tokens.create": "Create token",
"new.title": "New session",
"new.directory": "Project directory",
"new.up": "Up",
+5
View File
@@ -25,6 +25,11 @@ let current = detect();
document.documentElement.lang = current;
const listeners = new Set<() => void>();
export function onLangChange(fn: () => void) {
listeners.add(fn);
return () => void listeners.delete(fn);
}
export function getLang() {
return current;
}
+37
View File
@@ -0,0 +1,37 @@
import { get, put } from "./api";
import { getLang, onLangChange, setLang } from "./i18n";
import { applyTheme, getTheme, getThemeMode, onThemeChange, THEMES, type Theme, type ThemeMode } from "./theme";
/**
* Theme and language are stored per device (localStorage) and mirrored to the
* server, so the command line client uses the same colours and language and a
* new device starts with your settings.
*/
let timer: ReturnType<typeof setTimeout> | undefined;
let started = false;
function push() {
clearTimeout(timer);
timer = setTimeout(() => {
void put("/account/prefs", { theme: getTheme(), theme_mode: getThemeMode(), lang: getLang() }).catch(() => undefined);
}, 800);
}
export async function startPrefsSync() {
if (started) return;
started = true;
// A device without own settings takes the server's.
const fresh = !localStorage.getItem("codecraft.theme") && !localStorage.getItem("codecraft.themeMode");
try {
const p = await get<{ theme?: string; theme_mode?: string; lang?: string }>("/account/prefs");
if (fresh && p.theme && THEMES.some((t) => t.id === p.theme)) {
applyTheme(p.theme as Theme, (p.theme_mode as ThemeMode) ?? "auto");
}
if (fresh && p.lang) setLang(p.lang);
if (!p.theme) push();
} catch {
/* offline or old server */
}
onThemeChange(push);
onLangChange(push);
}
+14
View File
@@ -3040,3 +3040,17 @@ button.danger-solid {
color: var(--red);
animation: glow 1.2s ease-in-out infinite;
}
.token-value {
word-break: break-all;
background: var(--bg0);
padding: 4px 8px;
border-radius: 6px;
flex: 1;
}
.token-example {
margin: 8px 0 0;
white-space: pre-wrap;
word-break: break-all;
font-size: 12px;
}