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) => {
+4
View File
@@ -408,6 +408,10 @@ export const en: Translations = {
setupNeeded: "Setup needed",
disabledForCli: "Disabled for CLI",
more: "+{count} more",
profileSelector: "Profile",
currentProfile: "current ({name})",
managingProfile:
"Managing profile \u201c{name}\u201d — toggles apply to that profile, not this dashboard\u2019s.",
},
config: {
+6
View File
@@ -404,6 +404,8 @@ export interface Translations {
modelSaved?: string;
modelSelect?: string;
actions?: string;
manageSkills?: string;
activeSetHint?: string;
};
// ── Skills page ──
@@ -425,6 +427,10 @@ export interface Translations {
setupNeeded: string;
disabledForCli: string;
more: string;
/** Optional — fall back to English literals until translated. */
profileSelector?: string;
currentProfile?: string;
managingProfile?: string;
};
// ── Config page ──
+44 -22
View File
@@ -249,6 +249,14 @@ export async function buildWsUrl(
return `${proto}//${window.location.host}${BASE}${path}?${qs}`;
}
/** Build a ``?profile=<name>`` query suffix, or "" when unset.
*
* Used by the skills/toolsets endpoints so the dashboard can manage a
* profile other than the one the server process runs under. */
function profileQuery(profile?: string): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
}
export const api = {
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
/**
@@ -542,43 +550,49 @@ export const api = {
),
// Skills & Toolsets
getSkills: () => fetchJSON<SkillInfo[]>("/api/skills"),
toggleSkill: (name: string, enabled: boolean) =>
//
// All calls accept an optional ``profile`` so the Skills page can manage
// any profile's skills/toolsets — not just the one the dashboard process
// runs under. Omitted/empty profile = the dashboard's own profile.
getSkills: (profile?: string) =>
fetchJSON<SkillInfo[]>(`/api/skills${profileQuery(profile)}`),
toggleSkill: (name: string, enabled: boolean, profile?: string) =>
fetchJSON<{ ok: boolean }>("/api/skills/toggle", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, enabled }),
body: JSON.stringify({ name, enabled, profile: profile || undefined }),
}),
getToolsets: () => fetchJSON<ToolsetInfo[]>("/api/tools/toolsets"),
toggleToolset: (name: string, enabled: boolean) =>
getToolsets: (profile?: string) =>
fetchJSON<ToolsetInfo[]>(`/api/tools/toolsets${profileQuery(profile)}`),
toggleToolset: (name: string, enabled: boolean, profile?: string) =>
fetchJSON<{ ok: boolean; name: string; enabled: boolean }>(
`/api/tools/toolsets/${encodeURIComponent(name)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
body: JSON.stringify({ enabled, profile: profile || undefined }),
},
),
getToolsetConfig: (name: string) =>
getToolsetConfig: (name: string, profile?: string) =>
fetchJSON<ToolsetConfig>(
`/api/tools/toolsets/${encodeURIComponent(name)}/config`,
`/api/tools/toolsets/${encodeURIComponent(name)}/config${profileQuery(profile)}`,
),
selectToolsetProvider: (name: string, provider: string) =>
selectToolsetProvider: (name: string, provider: string, profile?: string) =>
fetchJSON<{ ok: boolean; name: string; provider: string }>(
`/api/tools/toolsets/${encodeURIComponent(name)}/provider`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider }),
body: JSON.stringify({ provider, profile: profile || undefined }),
},
),
saveToolsetEnv: (name: string, env: Record<string, string>) =>
saveToolsetEnv: (name: string, env: Record<string, string>, profile?: string) =>
fetchJSON<ToolsetEnvResult>(
`/api/tools/toolsets/${encodeURIComponent(name)}/env`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ env }),
body: JSON.stringify({ env, profile: profile || undefined }),
},
),
runToolsetPostSetup: (name: string, key: string) =>
@@ -986,26 +1000,34 @@ export const api = {
fetchJSON<ActionResponse>("/api/ops/checkpoints/prune", { method: "POST" }),
// ── Admin: Skills hub ───────────────────────────────────────────────
installSkillFromHub: (identifier: string) =>
// ``profile`` scopes install/uninstall/update and the installed-state
// annotations to that profile (omitted = the dashboard's own profile).
installSkillFromHub: (identifier: string, profile?: string) =>
fetchJSON<ActionResponse>("/api/skills/hub/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identifier }),
body: JSON.stringify({ identifier, profile: profile || undefined }),
}),
uninstallSkillFromHub: (name: string) =>
uninstallSkillFromHub: (name: string, profile?: string) =>
fetchJSON<ActionResponse>("/api/skills/hub/uninstall", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
body: JSON.stringify({ name, profile: profile || undefined }),
}),
updateSkillsFromHub: () =>
fetchJSON<ActionResponse>("/api/skills/hub/update", { method: "POST" }),
searchSkillsHub: (q: string, source = "all", limit = 20) =>
updateSkillsFromHub: (profile?: string) =>
fetchJSON<ActionResponse>("/api/skills/hub/update", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profile: profile || undefined }),
}),
searchSkillsHub: (q: string, source = "all", limit = 20, profile?: string) =>
fetchJSON<SkillHubSearchResponse>(
`/api/skills/hub/search?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}&limit=${limit}`,
`/api/skills/hub/search?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}&limit=${limit}${profile ? `&profile=${encodeURIComponent(profile)}` : ""}`,
),
getSkillHubSources: (profile?: string) =>
fetchJSON<SkillHubSourcesResponse>(
`/api/skills/hub/sources${profileQuery(profile)}`,
),
getSkillHubSources: () =>
fetchJSON<SkillHubSourcesResponse>("/api/skills/hub/sources"),
previewSkillFromHub: (identifier: string) =>
fetchJSON<SkillHubPreview>(
`/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`,
+33 -1
View File
@@ -22,6 +22,7 @@ import {
X,
} from "lucide-react";
import spinners from "unicode-animations";
import { useNavigate } from "react-router-dom";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api } from "@/lib/api";
import type { ActiveProfileInfo, ProfileInfo } from "@/lib/api";
@@ -96,6 +97,7 @@ function ProfileActionsMenu({
onEditDescription,
onEditModel,
onEditSoul,
onManageSkills,
onRename,
onSetActive,
}: ProfileActionsMenuProps) {
@@ -201,6 +203,16 @@ function ProfileActionsMenu({
{labels.editSoul}
</button>
<button
type="button"
role="menuitem"
className={itemClass}
onClick={run(onManageSkills)}
>
<Package className="h-4 w-4" />
{labels.manageSkills}
</button>
<button
type="button"
role="menuitem"
@@ -241,6 +253,7 @@ function ProfileActionsMenu({
}
export default function ProfilesPage() {
const navigate = useNavigate();
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
const [activeInfo, setActiveInfo] = useState<ActiveProfileInfo | null>(null);
const [loading, setLoading] = useState(true);
@@ -291,6 +304,10 @@ export default function ProfilesPage() {
modelSaved: p.modelSaved ?? "Model updated",
modelSelect: p.modelSelect ?? "Select a model",
actions: p.actions ?? "Actions",
manageSkills: p.manageSkills ?? "Manage skills & tools",
activeSetHint:
p.activeSetHint ??
"Applies to new CLI/gateway runs. This dashboard still manages its own profile — use “Manage skills & tools” to edit {name}.",
};
}, [t.profiles]);
@@ -480,7 +497,14 @@ export default function ProfilesPage() {
// The backend normalizes/validates the name; trust the canonical
// value it returns rather than the raw input.
const { active } = await api.setActiveProfile(name);
showToast(`${L.activeSet}: ${active}`, "success");
// "Set as active" only flips the sticky default for FUTURE CLI/gateway
// invocations — it does NOT retarget this running dashboard. Say so,
// or users assume skill/tool toggles now apply to the activated
// profile (they don't — that's what "Manage skills & tools" is for).
showToast(
`${L.activeSet}: ${active}${L.activeSetHint.replace("{name}", active)}`,
"success",
);
setActiveInfo((prev) =>
prev ? { ...prev, active } : { active, current: active },
);
@@ -1110,6 +1134,7 @@ export default function ProfilesPage() {
editModel: L.editModel,
editDescription: L.editDescription,
editSoul: t.profiles.editSoul,
manageSkills: L.manageSkills,
openInTerminal: t.profiles.openInTerminal,
rename: t.profiles.rename,
delete: t.common.delete,
@@ -1121,6 +1146,11 @@ export default function ProfilesPage() {
onEditDescription={() => openDescEditor(p)}
onEditModel={() => openModelEditor(p)}
onEditSoul={() => openSoulEditor(p.name)}
onManageSkills={() =>
navigate(
`/skills?profile=${encodeURIComponent(p.name)}`,
)
}
onRename={() => {
setRenamingFrom(p.name);
setRenameTo(p.name);
@@ -1375,6 +1405,7 @@ interface ProfileActionsMenuProps {
editDescription: string;
editModel: string;
editSoul: string;
manageSkills: string;
openInTerminal: string;
rename: string;
setActive: string;
@@ -1385,6 +1416,7 @@ interface ProfileActionsMenuProps {
onEditDescription: () => void;
onEditModel: () => void;
onEditSoul: () => void;
onManageSkills: () => void;
onRename: () => void;
onSetActive: () => void;
}
+134 -18
View File
@@ -25,6 +25,7 @@ import {
AlertTriangle,
Sparkles,
Loader2,
Users,
} from "lucide-react";
import { api } from "@/lib/api";
import type {
@@ -35,7 +36,9 @@ import type {
SkillHubInstalledEntry,
SkillHubPreview,
SkillHubScan,
ProfileInfo,
} from "@/lib/api";
import { useSearchParams } from "react-router-dom";
import { ToolsetConfigDrawer } from "@/components/ToolsetConfigDrawer";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { Toast } from "@nous-research/ui/ui/components/toast";
@@ -133,21 +136,79 @@ export default function SkillsPage() {
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
// ── Profile scoping ──
// The dashboard process runs under ONE profile, but skills/toolsets are
// per-profile state. Without an explicit selector, users who "activated"
// a profile on the Profiles page (which only affects FUTURE CLI/gateway
// runs) toggled skills here and silently wrote into the dashboard's own
// profile. The selector makes the write target explicit and deep-linkable
// via /skills?profile=<name>.
const [searchParams, setSearchParams] = useSearchParams();
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
const [currentProfile, setCurrentProfile] = useState<string>("");
const urlProfile = searchParams.get("profile") ?? "";
// "" = the dashboard's own profile (legacy behavior).
const selectedProfile = urlProfile;
const setSelectedProfile = useCallback(
(name: string) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (name) next.set("profile", name);
else next.delete("profile");
return next;
},
{ replace: true },
);
},
[setSearchParams],
);
// The profile actually being managed, for display purposes.
const managedProfile = selectedProfile || currentProfile || "default";
const managingOtherProfile =
!!selectedProfile && selectedProfile !== currentProfile;
useEffect(() => {
Promise.all([api.getSkills(), api.getToolsets()])
// Profile list + the dashboard's own profile, for the selector. Failure
// leaves the selector hidden — the page still works profile-unscoped.
api
.getProfiles()
.then((res) => setProfiles(res.profiles))
.catch(() => {});
api
.getActiveProfile()
.then((info) => setCurrentProfile(info.current || "default"))
.catch(() => setCurrentProfile("default"));
}, []);
useEffect(() => {
// Promise-chain shape: setState fires only inside async callbacks so the
// effect body stays lint-clean (react-hooks/set-state-in-effect). On a
// profile switch the old list stays visible until the new one arrives.
let cancelled = false;
Promise.all([
api.getSkills(selectedProfile || undefined),
api.getToolsets(selectedProfile || undefined),
])
.then(([s, tsets]) => {
if (cancelled) return;
setSkills(s);
setToolsets(tsets);
})
.catch(() => showToast(t.common.loading, "error"))
.finally(() => setLoading(false));
}, []);
.catch(() => !cancelled && showToast(t.common.loading, "error"))
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [selectedProfile]);
/* ---- Toggle skill ---- */
const handleToggleSkill = async (skill: SkillInfo) => {
setTogglingSkills((prev) => new Set(prev).add(skill.name));
try {
await api.toggleSkill(skill.name, !skill.enabled);
await api.toggleSkill(skill.name, !skill.enabled, selectedProfile || undefined);
setSkills((prev) =>
prev.map((s) =>
s.name === skill.name ? { ...s, enabled: !s.enabled } : s,
@@ -233,10 +294,37 @@ export default function SkillsPage() {
return;
}
setAfterTitle(
<span className="whitespace-nowrap text-xs text-muted-foreground">
<span className="flex items-center gap-2 whitespace-nowrap text-xs text-muted-foreground">
{t.skills.enabledOf
.replace("{enabled}", String(enabledCount))
.replace("{total}", String(skills.length))}
{profiles.length > 1 && (
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
<select
aria-label={t.skills.profileSelector ?? "Profile"}
className="h-6 rounded-none border border-border bg-background px-1 text-xs text-foreground"
value={selectedProfile}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setSelectedProfile(e.target.value)
}
>
<option value="">
{(t.skills.currentProfile ?? "current ({name})").replace(
"{name}",
currentProfile || "default",
)}
</option>
{profiles
.filter((p) => p.name !== currentProfile)
.map((p) => (
<option key={p.name} value={p.name}>
{p.name}
</option>
))}
</select>
</span>
)}
</span>,
);
setEnd(
@@ -265,7 +353,19 @@ export default function SkillsPage() {
setAfterTitle(null);
setEnd(null);
};
}, [enabledCount, loading, search, setAfterTitle, setEnd, skills.length, t]);
}, [
enabledCount,
loading,
search,
setAfterTitle,
setEnd,
skills.length,
t,
profiles,
selectedProfile,
currentProfile,
setSelectedProfile,
]);
const filteredToolsets = useMemo(() => {
return toolsets.filter(
@@ -291,6 +391,18 @@ export default function SkillsPage() {
<PluginSlot name="skills:top" />
<Toast toast={toast} />
{managingOtherProfile && (
<div className="flex items-center gap-2 border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-300">
<Users className="h-3.5 w-3.5 shrink-0" />
<span>
{(
t.skills.managingProfile ??
"Managing profile “{name}” — toggles apply to that profile, not this dashboards."
).replace("{name}", managedProfile)}
</span>
</div>
)}
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
<aside aria-label={t.skills.title} className="sm:w-56 sm:shrink-0">
<div className="sm:sticky sm:top-0">
@@ -540,13 +652,14 @@ export default function SkillsPage() {
)}
</>
) : (
<HubBrowser showToast={showToast} />
<HubBrowser showToast={showToast} profile={selectedProfile || undefined} />
)}
</div>
</div>
{configToolset && (
<ToolsetConfigDrawer
toolset={configToolset}
profile={selectedProfile || undefined}
onClose={() => setConfigToolset(null)}
onChanged={() => void refreshToolsets()}
/>
@@ -668,8 +781,11 @@ const SEVERITY_TONE: Record<string, "destructive" | "warning" | "secondary" | "o
function HubBrowser({
showToast,
profile,
}: {
showToast: (msg: string, kind: "success" | "error") => void;
/** Optional profile scoping installs + installed-state badges. */
profile?: string;
}) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SkillHubResult[]>([]);
@@ -699,7 +815,7 @@ function HubBrowser({
useEffect(() => {
let cancelled = false;
api
.getSkillHubSources()
.getSkillHubSources(profile)
.then((r) => {
if (cancelled) return;
setSources(r.sources);
@@ -715,7 +831,7 @@ function HubBrowser({
return () => {
cancelled = true;
};
}, []);
}, [profile]);
/* ---- Search ---- */
const runSearch = useCallback(async () => {
@@ -725,7 +841,7 @@ function HubBrowser({
setSearched(true);
const t0 = performance.now();
try {
const r = await api.searchSkillsHub(q);
const r = await api.searchSkillsHub(q, "all", 20, profile);
setResults(r.results);
setSourceCounts(r.source_counts || {});
setTimedOut(r.timed_out || []);
@@ -739,7 +855,7 @@ function HubBrowser({
setSearchMs(Math.round(performance.now() - t0));
setSearching(false);
}
}, [query, showToast]);
}, [query, showToast, profile]);
/* ---- Poll a spawned action's log until it exits ---- */
useEffect(() => {
@@ -757,7 +873,7 @@ function HubBrowser({
} else {
// Install finished — refresh installed-state so badges update.
api
.getSkillHubSources()
.getSkillHubSources(profile)
.then((r) => !cancelled && setInstalled(r.installed))
.catch(() => {});
}
@@ -770,12 +886,12 @@ function HubBrowser({
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [action]);
}, [action, profile]);
const install = useCallback(
async (identifier: string) => {
try {
const res = await api.installSkillFromHub(identifier);
const res = await api.installSkillFromHub(identifier, profile);
showToast(`Installing ${identifier}`, "success");
setActionLog([]);
setActionRunning(true);
@@ -785,12 +901,12 @@ function HubBrowser({
showToast(`Install failed: ${e}`, "error");
}
},
[showToast],
[showToast, profile],
);
const updateAll = useCallback(async () => {
try {
const res = await api.updateSkillsFromHub();
const res = await api.updateSkillsFromHub(profile);
showToast("Updating installed skills…", "success");
setActionLog([]);
setActionRunning(true);
@@ -798,7 +914,7 @@ function HubBrowser({
} catch (e) {
showToast(`Update failed: ${e}`, "error");
}
}, [showToast]);
}, [showToast, profile]);
const isInstalled = useCallback(
(identifier: string) => Boolean(installed[identifier]),