feat(dashboard): profile-scoped skills & toolsets management

'Set as active' on the Profiles page only flips the sticky active_profile
file (future CLI/gateway runs) — it never retargets the running dashboard
process. The skills/toolsets endpoints called bare load_config()/
save_config(), so after 'activating' a profile in the web UI, deactivating
a skill silently wrote into the dashboard's own profile and the activated
profile was untouched.

Backend:
- _profile_scope() context manager on the skills/toolsets endpoints:
  context-local HERMES_HOME override for call-time config resolution +
  cron-style locked swap of tools.skills_tool's import-time SKILLS_DIR
- profile param on /api/skills, /api/skills/toggle, /api/tools/toolsets*
  (list/toggle/config/provider/env), hub sources/search installed-state
- hub install/uninstall/update spawn 'hermes -p <profile> skills ...' so
  the child rebinds skills_hub.SKILLS_DIR at import (the override cannot
  reach import-time globals); profile validated -> 404/400 before spawn

Frontend:
- Skills page: profile selector (deep-linkable /skills?profile=<name>),
  amber banner naming the managed profile, threaded through skill toggles,
  toolset drawer, and hub browser
- Profiles page: 'Manage skills & tools' action per card; 'Set as active'
  toast now says it applies to new CLI/gateway runs only

Omitted profile keeps legacy behavior (dashboard's own profile).
This commit is contained in:
Teknium
2026-06-10 20:34:53 -07:00
parent acd7932c0f
commit 914befa9aa
8 changed files with 662 additions and 152 deletions
+9 -6
View File
@@ -20,6 +20,9 @@ import { cn, themedBody } from "@/lib/utils";
interface Props {
/** The toolset whose backends are being configured. */
toolset: ToolsetInfo;
/** Optional profile to scope config reads/writes to (Skills page profile
* selector). Omitted = the dashboard process's own profile. */
profile?: string;
onClose: () => void;
/** Called after a toggle/provider/key change so the parent grid refreshes. */
onChanged: () => void;
@@ -31,7 +34,7 @@ interface Props {
* the toolset on/off, pick a provider, enter API keys, and run a provider's
* post-setup install hook (npm/pip/binary) with a live log tail.
*/
export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Props) {
const { toast, showToast } = useToast();
const [config, setConfig] = useState<ToolsetConfig | null>(null);
const [loading, setLoading] = useState(true);
@@ -60,7 +63,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
// react-hooks/set-state-in-effect — setState only fires inside the
// async .then/.catch/.finally callbacks.
return api
.getToolsetConfig(toolset.name)
.getToolsetConfig(toolset.name, profile)
.then((cfg) => {
setConfig(cfg);
setActiveProvider(cfg.active_provider);
@@ -72,7 +75,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
})
.catch(() => showToast("Failed to load toolset config", "error"))
.finally(() => setLoading(false));
}, [toolset.name, showToast]);
}, [toolset.name, profile, showToast]);
useEffect(() => {
void loadConfig();
@@ -121,7 +124,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
const handleToggle = async (next: boolean) => {
setToggling(true);
try {
await api.toggleToolset(toolset.name, next);
await api.toggleToolset(toolset.name, next, profile);
setEnabled(next);
showToast(
`${toolset.label || toolset.name} ${next ? "enabled" : "disabled"}`,
@@ -138,7 +141,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
const handleSelectProvider = async (provider: ToolsetProvider) => {
setSelecting(provider.name);
try {
await api.selectToolsetProvider(toolset.name, provider.name);
await api.selectToolsetProvider(toolset.name, provider.name, profile);
setActiveProvider(provider.name);
showToast(`Provider set to ${provider.name}`, "success");
onChanged();
@@ -164,7 +167,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
}
setSavingProvider(provider.name);
try {
const res = await api.saveToolsetEnv(toolset.name, env);
const res = await api.saveToolsetEnv(toolset.name, env, profile);
setIsSet((prev) => ({ ...prev, ...res.is_set }));
// Clear saved drafts so the inputs reset to the "saved" placeholder.
setDrafts((prev) => {