diff --git a/apps/dashboard/src/RuntimeInventorySettings.css b/apps/dashboard/src/RuntimeInventorySettings.css index c67465e..c40e826 100644 --- a/apps/dashboard/src/RuntimeInventorySettings.css +++ b/apps/dashboard/src/RuntimeInventorySettings.css @@ -171,6 +171,43 @@ font-size: 0.86rem; } +.runtime-room-skill-toggles { + display: grid; + gap: 8px; + margin-top: 8px; +} + +.runtime-room-skill-toggle { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 10px; + min-height: 44px; + border-radius: 12px; + background: rgba(15, 23, 42, 0.38); + padding: 8px 10px; +} + +.runtime-room-skill-toggle input { + width: 18px; + height: 18px; +} + +.runtime-room-skill-toggle > span { + display: grid; + gap: 2px; + min-width: 0; +} + +.runtime-room-skill-toggle strong { + color: var(--text); + font-size: 0.84rem; +} + +.runtime-room-skill-toggle small { + color: var(--muted); +} + @media (max-width: 980px) { .runtime-summary-card, .runtime-skill-grid, diff --git a/apps/dashboard/src/RuntimeInventorySettings.tsx b/apps/dashboard/src/RuntimeInventorySettings.tsx index 7753f94..af7a1d3 100644 --- a/apps/dashboard/src/RuntimeInventorySettings.tsx +++ b/apps/dashboard/src/RuntimeInventorySettings.tsx @@ -3,11 +3,13 @@ import { useEffect, useState } from 'react'; import { fetchRoomSkillSettings, fetchRuntimeInventory, + updateRoomSkillSetting, type RuntimeAgentInventory, type RuntimeInventorySnapshot, type RuntimePathSnapshot, type RuntimeSkillDirSnapshot, type RoomSkillCatalogItem, + type RoomSkillSettingUpdateInput, type RoomSkillSettingsSnapshot, } from './api'; import { SettingsSectionHeading } from './SettingsPanelChrome'; @@ -106,8 +108,12 @@ function agentLabel(agentType: 'claude-code' | 'codex') { } function RoomSkillPolicyCard({ + onToggle, + savingKey, snapshot, }: { + onToggle: (input: RoomSkillSettingUpdateInput) => void; + savingKey: string | null; snapshot: RoomSkillSettingsSnapshot | null; }) { if (!snapshot) { @@ -159,6 +165,7 @@ function RoomSkillPolicyCard({ const disabledNames = agent.disabledSkillIds .map((id) => catalogById.get(id)?.displayName ?? id) .slice(0, 3); + const enabledIds = new Set(agent.effectiveEnabledSkillIds); return (
0 ? ( OFF: {disabledNames.join(', ')} ) : null} +
+ {agent.availableSkillIds.map((skillId) => { + const skill = catalogById.get(skillId); + const key = `${room.jid}:${agent.agentType}:${skillId}`; + return ( + + ); + })} +
); })} @@ -201,6 +241,10 @@ export function RuntimeInventorySettings() { const [roomSkillSnapshot, setRoomSkillSnapshot] = useState(null); const [error, setError] = useState(null); + const [roomSkillError, setRoomSkillError] = useState(null); + const [savingRoomSkillKey, setSavingRoomSkillKey] = useState( + null, + ); useEffect(() => { let cancelled = false; @@ -220,6 +264,20 @@ export function RuntimeInventorySettings() { }; }, []); + async function handleRoomSkillToggle(input: RoomSkillSettingUpdateInput) { + const key = `${input.roomJid}:${input.agentType}:${input.skillId}`; + setSavingRoomSkillKey(key); + setRoomSkillError(null); + try { + const next = await updateRoomSkillSetting(input); + setRoomSkillSnapshot(next); + } catch (err) { + setRoomSkillError(err instanceof Error ? err.message : String(err)); + } finally { + setSavingRoomSkillKey(null); + } + } + return (
- + {roomSkillError ? ( +

{roomSkillError}

+ ) : null} + { + void handleRoomSkillToggle(input); + }} + savingKey={savingRoomSkillKey} + snapshot={roomSkillSnapshot} + />
diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index ce1dbe6..02ffd57 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -473,6 +473,13 @@ export interface RoomSkillSettingsSnapshot { rooms: RoomSkillPolicyRoom[]; } +export interface RoomSkillSettingUpdateInput { + roomJid: string; + agentType: 'claude-code' | 'codex'; + skillId: string; + enabled: boolean; +} + export interface MoaReferenceStatus { model: string; checkedAt: string; @@ -635,6 +642,30 @@ export async function fetchRoomSkillSettings(): Promise { + const response = await fetch('/api/settings/room-skills', { + method: 'PATCH', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify(input), + }); + if (!response.ok) { + let msg = `update room skill failed: ${response.status}`; + try { + const payload = (await response.json()) as { error?: string }; + if (payload.error) msg = payload.error; + } catch { + /* ignore */ + } + throw new Error(msg); + } + return (await response.json()) as RoomSkillSettingsSnapshot; +} + export async function updateCodexFeatures( input: Partial, ): Promise { diff --git a/scripts/dashboard-ux.ts b/scripts/dashboard-ux.ts index 28071bc..2f6b0ea 100644 --- a/scripts/dashboard-ux.ts +++ b/scripts/dashboard-ux.ts @@ -11,6 +11,8 @@ interface MockApiState { codexFeatureUpdates: number; ciWatcherFailures: number; restartRequests: number; + roomSkillUpdates: number; + roomSkillsDisableCodexBrowser: boolean; } const seriousImpacts = new Set(['serious', 'critical']); @@ -27,7 +29,7 @@ async function main() { 'settings nav keeps hash route stable', browser, baseUrl, - async (page) => { + async (page, state) => { await openSettings(page, baseUrl); const originalUrl = page.url(); @@ -52,6 +54,17 @@ async function main() { await assertVisible(page.locator('#settings-runtime')); await page.getByText('Runtime inventory').waitFor(); await page.getByText('EJClaw bridge').waitFor(); + const codexPolicy = page + .locator('.runtime-room-agent-policy') + .filter({ hasText: 'Codex' }) + .first(); + const codexBrowserToggle = codexPolicy + .locator('input[type="checkbox"]') + .first(); + assert.equal(await codexBrowserToggle.isChecked(), true); + await codexBrowserToggle.click(); + await codexPolicy.getByText('1개 OFF').waitFor(); + assert.equal(state.roomSkillUpdates, 1); await page .locator( @@ -225,6 +238,8 @@ function createMockApiState(): MockApiState { codexFeatureUpdates: 0, ciWatcherFailures: 0, restartRequests: 0, + roomSkillUpdates: 0, + roomSkillsDisableCodexBrowser: false, }; } @@ -319,8 +334,7 @@ async function handleMockApi(route: Route, state: MockApiState) { return; } - if (method === 'GET' && url.pathname === '/api/settings/room-skills') { - await fulfillJson(route, roomSkillSettingsFixture()); + if (await handleMockRoomSkillRoute(route, state, url, method)) { return; } @@ -419,6 +433,33 @@ async function handleMockApi(route: Route, state: MockApiState) { ); } +async function handleMockRoomSkillRoute( + route: Route, + state: MockApiState, + url: URL, + method: string, +): Promise { + if (url.pathname !== '/api/settings/room-skills') return false; + if (method === 'GET') { + await fulfillJson(route, roomSkillSettingsFixture(state)); + return true; + } + if (method !== 'PATCH' && method !== 'PUT') return false; + + const body = parseJsonBody(route.request().postData()); + if ( + body.roomJid === 'room@example' && + body.agentType === 'codex' && + body.skillId === 'codex-user:agent-browser' && + body.enabled === false + ) { + state.roomSkillUpdates += 1; + state.roomSkillsDisableCodexBrowser = true; + } + await fulfillJson(route, roomSkillSettingsFixture(state)); + return true; +} + function mockOverview(state: MockApiState) { return { generatedAt: MOCK_TIME, @@ -588,7 +629,8 @@ function runtimeInventoryFixture() { }; } -function roomSkillSettingsFixture() { +function roomSkillSettingsFixture(state?: MockApiState) { + const codexDisabled = state?.roomSkillsDisableCodexBrowser === true; return { generatedAt: '2026-05-04T00:00:00.000Z', catalog: [ @@ -629,17 +671,16 @@ function roomSkillSettingsFixture() { agents: [ { agentType: 'codex', - mode: 'all-enabled', + mode: codexDisabled ? 'custom' : 'all-enabled', availableSkillIds: [ 'codex-user:agent-browser', 'runner:agent-browser', ], - disabledSkillIds: [], + disabledSkillIds: codexDisabled ? ['codex-user:agent-browser'] : [], explicitEnabledSkillIds: [], - effectiveEnabledSkillIds: [ - 'codex-user:agent-browser', - 'runner:agent-browser', - ], + effectiveEnabledSkillIds: codexDisabled + ? ['runner:agent-browser'] + : ['codex-user:agent-browser', 'runner:agent-browser'], }, { agentType: 'claude-code', diff --git a/src/db.ts b/src/db.ts index 34b2704..90f346c 100644 --- a/src/db.ts +++ b/src/db.ts @@ -43,6 +43,7 @@ export { createTask, deleteAllSessionsForGroup, deleteSession, + deleteStoredRoomSkillOverride, deleteTask, findDuplicateCiWatcher, getAllRoomBindings, @@ -72,6 +73,7 @@ export { setRouterStateForService, setSession, setSessionForAgentType, + upsertStoredRoomSkillOverride, updateRegisteredGroupName, updateTask, updateTaskAfterRun, diff --git a/src/db/rooms.ts b/src/db/rooms.ts index f9d9640..940f12b 100644 --- a/src/db/rooms.ts +++ b/src/db/rooms.ts @@ -38,6 +38,14 @@ export interface StoredRoomSkillOverride { updatedAt: string; } +export interface StoredRoomSkillOverrideInput { + chatJid: string; + agentType: AgentType; + skillScope: string; + skillName: string; + enabled: boolean; +} + export interface AssignRoomInput { name: string; roomMode?: RoomMode; @@ -373,6 +381,56 @@ export function getStoredRoomSkillOverridesFromDatabase( .filter((row): row is StoredRoomSkillOverride => Boolean(row)); } +export function upsertStoredRoomSkillOverrideInDatabase( + database: Database, + input: StoredRoomSkillOverrideInput, +): void { + const agentType = normalizeStoredAgentType(input.agentType); + if (!agentType) { + throw new Error(`Unsupported agent type: ${input.agentType}`); + } + const now = new Date().toISOString(); + database + .prepare( + `INSERT INTO room_skill_overrides ( + chat_jid, agent_type, skill_scope, skill_name, enabled, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(chat_jid, agent_type, skill_scope, skill_name) + DO UPDATE SET + enabled = excluded.enabled, + updated_at = excluded.updated_at`, + ) + .run( + input.chatJid, + agentType, + input.skillScope, + input.skillName, + input.enabled ? 1 : 0, + now, + now, + ); +} + +export function deleteStoredRoomSkillOverrideFromDatabase( + database: Database, + input: Omit, +): void { + const agentType = normalizeStoredAgentType(input.agentType); + if (!agentType) { + throw new Error(`Unsupported agent type: ${input.agentType}`); + } + database + .prepare( + `DELETE FROM room_skill_overrides + WHERE chat_jid = ? + AND agent_type = ? + AND skill_scope = ? + AND skill_name = ?`, + ) + .run(input.chatJid, agentType, input.skillScope, input.skillName); +} + function getStoredRoomModeRowFromDatabase( database: Database, chatJid: string, diff --git a/src/db/runtime-tasks-rooms.ts b/src/db/runtime-tasks-rooms.ts index a1a8b7e..45ccca6 100644 --- a/src/db/runtime-tasks-rooms.ts +++ b/src/db/runtime-tasks-rooms.ts @@ -28,6 +28,7 @@ import { assignRoomInDatabase, clearExplicitRoomModeInDatabase, deleteStoredRoomSettingsForTestsInDatabase, + deleteStoredRoomSkillOverrideFromDatabase, getAllRoomBindingsFromDatabase, getEffectiveRoomModeFromDatabase, getEffectiveRuntimeRoomModeFromDatabase, @@ -41,7 +42,9 @@ import { setRegisteredGroupForTestsInDatabase, setStoredRoomOwnerAgentTypeForTestsInDatabase, type StoredRoomSkillOverride, + type StoredRoomSkillOverrideInput, updateRegisteredGroupNameInDatabase, + upsertStoredRoomSkillOverrideInDatabase, } from './rooms.js'; import { type StoredRoomSettings } from './room-registration.js'; import { @@ -373,6 +376,18 @@ export function getStoredRoomSkillOverrides( return getStoredRoomSkillOverridesFromDatabase(db, chatJid); } +export function upsertStoredRoomSkillOverride( + input: StoredRoomSkillOverrideInput, +): void { + upsertStoredRoomSkillOverrideInDatabase(requireDatabase(), input); +} + +export function deleteStoredRoomSkillOverride( + input: Omit, +): void { + deleteStoredRoomSkillOverrideFromDatabase(requireDatabase(), input); +} + export function getExplicitRoomMode(chatJid: string): RoomMode | undefined { return getExplicitRoomModeFromDatabase(requireDatabase(), chatJid); } diff --git a/src/room-skill-settings.test.ts b/src/room-skill-settings.test.ts index 813b873..88cc6b5 100644 --- a/src/room-skill-settings.test.ts +++ b/src/room-skill-settings.test.ts @@ -3,7 +3,11 @@ import { Database } from 'bun:sqlite'; import { describe, expect, it } from 'vitest'; import { initializeDatabaseSchema } from './db/bootstrap.js'; -import { getStoredRoomSkillOverridesFromDatabase } from './db/rooms.js'; +import { + deleteStoredRoomSkillOverrideFromDatabase, + getStoredRoomSkillOverridesFromDatabase, + upsertStoredRoomSkillOverrideInDatabase, +} from './db/rooms.js'; import { buildRoomSkillSettingsSnapshot, type RoomSkillSettingsBuildInput, @@ -146,6 +150,39 @@ describe('room skill settings', () => { }); }); + it('keeps default mode when explicit enable rows do not change effective skills', () => { + const snapshot = buildRoomSkillSettingsSnapshot({ + generatedAt: '2026-05-04T00:00:00.000Z', + inventory: inventoryFixture(), + roomBindings: { + 'room-1': { + name: 'Room One', + folder: 'room-one', + added_at: '2026-05-04T00:00:00.000Z', + agentType: 'codex', + }, + }, + registeredAgentTypesByJid: new Map([['room-1', ['codex']]]), + overrides: [ + { + chatJid: 'room-1', + agentType: 'codex', + skillScope: 'runner', + skillName: 'runner-browser', + enabled: true, + createdAt: '2026-05-04T00:00:00.000Z', + updatedAt: '2026-05-04T00:00:00.000Z', + }, + ], + }); + + expect(snapshot.rooms[0]?.agents[0]).toMatchObject({ + mode: 'all-enabled', + explicitEnabledSkillIds: ['runner:runner-browser'], + effectiveEnabledSkillIds: ['codex-user:browser', 'runner:runner-browser'], + }); + }); + it('reads normalized room skill overrides from the database', () => { const database = new Database(':memory:'); try { @@ -192,4 +229,58 @@ describe('room skill settings', () => { database.close(); } }); + + it('upserts and deletes room skill overrides in the database', () => { + const database = new Database(':memory:'); + try { + initializeDatabaseSchema(database); + database + .prepare( + `INSERT INTO room_settings ( + chat_jid, room_mode, mode_source, name, folder, updated_at + ) VALUES (?, 'single', 'explicit', ?, ?, ?)`, + ) + .run('room-1', 'Room One', 'room-one', '2026-05-04T00:00:00.000Z'); + + upsertStoredRoomSkillOverrideInDatabase(database, { + chatJid: 'room-1', + agentType: 'codex', + skillScope: 'runner', + skillName: 'agent-browser', + enabled: false, + }); + expect(getStoredRoomSkillOverridesFromDatabase(database)).toMatchObject([ + { + chatJid: 'room-1', + agentType: 'codex', + skillScope: 'runner', + skillName: 'agent-browser', + enabled: false, + }, + ]); + + upsertStoredRoomSkillOverrideInDatabase(database, { + chatJid: 'room-1', + agentType: 'codex', + skillScope: 'runner', + skillName: 'agent-browser', + enabled: true, + }); + expect( + getStoredRoomSkillOverridesFromDatabase(database)[0], + ).toMatchObject({ + enabled: true, + }); + + deleteStoredRoomSkillOverrideFromDatabase(database, { + chatJid: 'room-1', + agentType: 'codex', + skillScope: 'runner', + skillName: 'agent-browser', + }); + expect(getStoredRoomSkillOverridesFromDatabase(database)).toEqual([]); + } finally { + database.close(); + } + }); }); diff --git a/src/room-skill-settings.ts b/src/room-skill-settings.ts index 448a064..ab073d7 100644 --- a/src/room-skill-settings.ts +++ b/src/room-skill-settings.ts @@ -1,9 +1,11 @@ import path from 'node:path'; import { + deleteStoredRoomSkillOverride, getAllRoomBindings, getRegisteredAgentTypesForJid, getStoredRoomSkillOverrides, + upsertStoredRoomSkillOverride, } from './db.js'; import { getRuntimeInventory, @@ -57,6 +59,23 @@ export interface RoomSkillSettingsBuildInput { overrides?: StoredRoomSkillOverride[]; } +export interface RoomSkillSettingUpdateInput { + roomJid: string; + agentType: string; + skillId: string; + enabled: boolean; +} + +export class RoomSkillSettingsError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = 'RoomSkillSettingsError'; + } +} + function skillId(scope: RoomSkillScope, name: string): string { return `${scope}:${name}`; } @@ -134,6 +153,26 @@ function overrideKey( return `${jid}\u0000${agentType}\u0000${scope}\u0000${name}`; } +function parseSkillId(id: string): { scope: RoomSkillScope; name: string } { + const separator = id.indexOf(':'); + if (separator <= 0 || separator === id.length - 1) { + throw new RoomSkillSettingsError('skillId must be ":"', 400); + } + const scope = id.slice(0, separator); + const name = id.slice(separator + 1); + if (scope !== 'codex-user' && scope !== 'claude-user' && scope !== 'runner') { + throw new RoomSkillSettingsError(`Unsupported skill scope: ${scope}`, 400); + } + return { scope, name }; +} + +function normalizeRoomSkillAgentType(agentType: string): AgentType { + if (agentType === 'codex' || agentType === 'claude-code') { + return agentType; + } + throw new RoomSkillSettingsError(`Unsupported agent type: ${agentType}`, 400); +} + function resolveRoomAgentTypes( jid: string, group: RegisteredGroup, @@ -202,7 +241,8 @@ export function buildRoomSkillSettingsSnapshot({ return { agentType, mode: - disabledSkillIds.length > 0 || explicitEnabledSkillIds.length > 0 + disabledSkillIds.length > 0 || + effectiveEnabledSkillIds.length !== available.length ? 'custom' : 'all-enabled', availableSkillIds: available.map((skill) => skill.id), @@ -237,3 +277,53 @@ export function getRoomSkillSettings(): RoomSkillSettingsSnapshot { overrides: getStoredRoomSkillOverrides(), }); } + +export function updateRoomSkillSetting( + input: RoomSkillSettingUpdateInput, +): RoomSkillSettingsSnapshot { + const roomJid = input.roomJid.trim(); + const agentType = normalizeRoomSkillAgentType(input.agentType); + const { scope, name } = parseSkillId(input.skillId.trim()); + const before = getRoomSkillSettings(); + const room = before.rooms.find((candidate) => candidate.jid === roomJid); + if (!room) { + throw new RoomSkillSettingsError('Room not found', 404); + } + const agent = room.agents.find( + (candidate) => candidate.agentType === agentType, + ); + if (!agent) { + throw new RoomSkillSettingsError( + 'Agent is not registered for this room', + 400, + ); + } + const skill = before.catalog.find( + (candidate) => candidate.scope === scope && candidate.name === name, + ); + if (!skill || !agent.availableSkillIds.includes(skill.id)) { + throw new RoomSkillSettingsError( + 'Skill is not available for this room agent', + 400, + ); + } + + if (input.enabled) { + deleteStoredRoomSkillOverride({ + chatJid: roomJid, + agentType, + skillScope: scope, + skillName: name, + }); + } else { + upsertStoredRoomSkillOverride({ + chatJid: roomJid, + agentType, + skillScope: scope, + skillName: name, + enabled: false, + }); + } + + return getRoomSkillSettings(); +} diff --git a/src/web-dashboard-settings-routes.test.ts b/src/web-dashboard-settings-routes.test.ts index f3ca628..f2b99dc 100644 --- a/src/web-dashboard-settings-routes.test.ts +++ b/src/web-dashboard-settings-routes.test.ts @@ -241,6 +241,7 @@ function makeDeps( updateFastMode: () => fastMode, updateModelConfig: () => modelConfig, updateMoaSettings: () => moaSettings, + updateRoomSkillSetting: () => roomSkillSettings, ...overrides, }; } @@ -336,6 +337,25 @@ describe('web dashboard settings routes', () => { calls.push(`moa:${JSON.stringify(input)}`); return moaSettings; }, + updateRoomSkillSetting: (input) => { + calls.push(`room-skill:${JSON.stringify(input)}`); + return { + ...roomSkillSettings, + rooms: [ + { + ...roomSkillSettings.rooms[0]!, + agents: [ + { + ...roomSkillSettings.rooms[0]!.agents[0]!, + mode: 'custom', + disabledSkillIds: [input.skillId], + effectiveEnabledSkillIds: [], + }, + ], + }, + ], + }; + }, }); const modelUpdate = await route( @@ -404,6 +424,31 @@ describe('web dashboard settings routes', () => { status: { model: 'glm', ok: true }, }); + const roomSkill = await route( + '/api/settings/room-skills', + 'PATCH', + { + roomJid: 'room@example', + agentType: 'codex', + skillId: 'runner:agent-browser', + enabled: false, + }, + deps, + ); + expect(roomSkill?.status).toBe(200); + await expect(roomSkill?.json()).resolves.toMatchObject({ + rooms: [ + { + agents: [ + { + mode: 'custom', + disabledSkillIds: ['runner:agent-browser'], + }, + ], + }, + ], + }); + expect(calls).toEqual([ 'models:{"owner":{"model":"gpt-5.5"}}', 'add:claude-token', @@ -411,6 +456,42 @@ describe('web dashboard settings routes', () => { 'current:4', 'codex-features:{"goals":true}', 'moa:{"enabled":false,"models":[{"name":"kimi","enabled":false}]}', + 'room-skill:{"roomJid":"room@example","agentType":"codex","skillId":"runner:agent-browser","enabled":false}', ]); }); + + it('rejects unsupported room skill methods with 405', async () => { + const response = await route('/api/settings/room-skills', 'DELETE'); + expect(response?.status).toBe(405); + expect(response?.headers.get('allow')).toBe('GET, HEAD, PATCH, PUT'); + await expect(response?.json()).resolves.toEqual({ + error: 'Method not allowed', + }); + }); + + it('validates room skill mutation payloads', async () => { + const missingEnabled = await route('/api/settings/room-skills', 'PATCH', { + roomJid: 'room@example', + agentType: 'codex', + skillId: 'runner:agent-browser', + }); + expect(missingEnabled?.status).toBe(400); + + const invalid = await route( + '/api/settings/room-skills', + 'PATCH', + { + roomJid: 'room@example', + agentType: 'codex', + skillId: 'runner:agent-browser', + enabled: false, + }, + makeDeps({ + updateRoomSkillSetting: () => { + throw new Error('boom'); + }, + }), + ); + expect(invalid?.status).toBe(500); + }); }); diff --git a/src/web-dashboard-settings-routes.ts b/src/web-dashboard-settings-routes.ts index 0468153..2d40652 100644 --- a/src/web-dashboard-settings-routes.ts +++ b/src/web-dashboard-settings-routes.ts @@ -25,7 +25,10 @@ import { } from './runtime-inventory.js'; import { getRoomSkillSettings, + RoomSkillSettingsError, + updateRoomSkillSetting, type RoomSkillSettingsSnapshot, + type RoomSkillSettingUpdateInput, } from './room-skill-settings.js'; import { checkMoaModel, @@ -60,6 +63,7 @@ export interface SettingsRouteDependencies { updateFastMode: typeof updateFastMode; updateModelConfig: typeof updateModelConfig; updateMoaSettings: typeof updateMoaSettings; + updateRoomSkillSetting: typeof updateRoomSkillSetting; } interface SettingsRouteContext { @@ -89,6 +93,7 @@ const defaultSettingsRouteDependencies: SettingsRouteDependencies = { updateFastMode, updateModelConfig, updateMoaSettings, + updateRoomSkillSetting, }; function readMethod(method: string): boolean { @@ -118,6 +123,19 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +function methodNotAllowed( + jsonResponse: JsonResponse, + allowed: string[], +): Response { + return jsonResponse( + { error: 'Method not allowed' }, + { + status: 405, + headers: { Allow: allowed.join(', ') }, + }, + ); +} + async function handleModelSettingsRoute( request: Request, jsonResponse: JsonResponse, @@ -228,13 +246,42 @@ function handleRuntimeInventoryRoute( return jsonResponse(deps.getRuntimeInventory()); } -function handleRoomSkillsRoute( +async function handleRoomSkillsRoute( request: Request, jsonResponse: JsonResponse, deps: SettingsRouteDependencies, -): Response | null { - if (!readMethod(request.method)) return null; - return jsonResponse(deps.getRoomSkillSettings()); +): Promise { + if (readMethod(request.method)) { + return jsonResponse(deps.getRoomSkillSettings()); + } + if (request.method !== 'PATCH' && request.method !== 'PUT') { + return methodNotAllowed(jsonResponse, ['GET', 'HEAD', 'PATCH', 'PUT']); + } + + const body = await readJsonObject(request, jsonResponse); + if (body instanceof Response) return body; + + const input: RoomSkillSettingUpdateInput = { + roomJid: typeof body.roomJid === 'string' ? body.roomJid : '', + agentType: typeof body.agentType === 'string' ? body.agentType : '', + skillId: typeof body.skillId === 'string' ? body.skillId : '', + enabled: body.enabled === true, + }; + if (typeof body.enabled !== 'boolean') { + return jsonResponse( + { error: 'enabled must be a boolean' }, + { status: 400 }, + ); + } + + try { + return jsonResponse(deps.updateRoomSkillSetting(input)); + } catch (err) { + if (err instanceof RoomSkillSettingsError) { + return jsonResponse({ error: err.message }, { status: err.status }); + } + return jsonResponse({ error: errorMessage(err) }, { status: 500 }); + } } async function handleClaudeAccountAddRoute(