diff --git a/apps/desktop/src/main/extension-inventory.ts b/apps/desktop/src/main/extension-inventory.ts new file mode 100644 index 00000000..17c7369d --- /dev/null +++ b/apps/desktop/src/main/extension-inventory.ts @@ -0,0 +1,99 @@ +import { readFile } from 'node:fs/promises' +import { basename } from 'node:path' +import type { DriverKind } from '@ari/contracts/common' +import type { RpcResults } from '@ari/contracts/rpc' +import { discoverExtensions, type ExtensionRecord } from '@ari/providers/extensions' +import { realDetectEnvironment, type DetectEnvironment } from '@ari/providers/types' +import type { McpServerConfig } from '@ari/ari-core/mcp-servers' +import { sanitizeMcpSegment } from '@ari/ari-core/mcp-tools' +import type { AriSkillRecord } from '@ari/ari-core/skills' + +const READ_CAP = 64 * 1024 + +const BROWSER_SUMMARY = + 'Sent as HTTP only when the agent advertises mcpCapabilities.http. Otherwise the stdio proxy is sent. Never both.' + +export function browserExtensionRecord(provider: DriverKind): ExtensionRecord { + return { + kind: 'mcp', + scope: 'ari', + provider, + id: 'ari:browser', + name: 'ari-browser', + summary: BROWSER_SUMMARY, + sourcePath: null, + transport: 'http', + disabled: false, + delivery: 'injected', + } +} + +export async function buildExtensionInventory(input: { + kind: DriverKind + workspacePath: string | null + env?: DetectEnvironment + coreServers?: McpServerConfig[] + coreSkills?: AriSkillRecord[] +}): Promise { + if (input.kind === 'ari-core') { + const records = [ + ...coreMcpRecords(input.coreServers ?? []), + ...coreSkillRecords(input.coreSkills ?? []), + browserExtensionRecord('ari-core'), + ] + return { records, truncated: false } + } + const discovered = await discoverExtensions({ + provider: input.kind, + workspacePath: input.workspacePath, + env: input.env ?? realDetectEnvironment(), + }) + return { + records: [...discovered.records, browserExtensionRecord(input.kind)], + truncated: discovered.truncated, + } +} + +export async function readInventoriedSkill( + inventory: RpcResults['providers.extensionInventory'], + path: string, +): Promise { + const allowed = inventory.records.some( + (record) => record.kind === 'skill' && record.sourcePath === path, + ) + if (!allowed) throw new Error('that file is not a skill in this inventory') + const raw = await readFile(path, 'utf8') + const truncated = raw.length > READ_CAP + return { content: truncated ? raw.slice(0, READ_CAP) : raw, truncated } +} + +function coreMcpRecords(servers: McpServerConfig[]): ExtensionRecord[] { + return servers.map((server) => ({ + kind: 'mcp' as const, + scope: 'user' as const, + provider: 'ari-core' as const, + id: `ari-core:user:mcp:${server.id}:${server.name}`, + name: server.name, + sourcePath: null, + transport: 'stdio' as const, + command: basename(server.command), + disabled: server.disabled, + delivery: 'hosted' as const, + ...(sanitizeMcpSegment(server.name) === 'ari_browser' ? { problem: 'duplicate-name' as const } : {}), + })) +} + +function coreSkillRecords(skills: AriSkillRecord[]): ExtensionRecord[] { + return skills.map((skill) => ({ + kind: 'skill' as const, + scope: skill.scope, + provider: 'ari-core' as const, + id: `ari-core:${skill.scope}:skill:${skill.sourcePath}:${skill.name}`, + name: skill.name, + ...(skill.summary ? { summary: skill.summary } : {}), + sourcePath: skill.sourcePath, + disabled: false, + delivery: 'hosted' as const, + ...(skill.problem ? { problem: skill.problem } : {}), + })) +} diff --git a/apps/desktop/src/main/ipc-methods.ts b/apps/desktop/src/main/ipc-methods.ts index 26c85730..29ba6314 100644 --- a/apps/desktop/src/main/ipc-methods.ts +++ b/apps/desktop/src/main/ipc-methods.ts @@ -49,6 +49,12 @@ export const IPC_METHODS = [ 'providers.configFiles', 'providers.readConfig', 'providers.writeConfig', + 'providers.extensionInventory', + 'providers.readExtensionFile', + 'ariCore.mcp.list', + 'ariCore.mcp.upsert', + 'ariCore.mcp.remove', + 'ariCore.skills.trust', 'window.minimize', 'window.toggleMaximize', 'window.close', diff --git a/apps/desktop/src/main/rpc.ts b/apps/desktop/src/main/rpc.ts index 49cdc844..9dd66c2a 100644 --- a/apps/desktop/src/main/rpc.ts +++ b/apps/desktop/src/main/rpc.ts @@ -48,6 +48,7 @@ import { import { BrowserService } from './browser-service' import { createElectronBrowserGuest } from './browser-electron' import { startBrowserMcpServer, writeBrowserMcpProxy } from './browser-mcp' +import { buildExtensionInventory, readInventoriedSkill } from './extension-inventory' import { ensureProjectWatched, getIndexedFiles, stopWatchingProject } from './watcher-bridge' import { createAppUpdater } from './updater' import type { UpdateController } from './update-controller' @@ -98,6 +99,10 @@ import { importPiSessionCandidate, listImportableSessions } from './session-impo import type { SessionImportDeps } from './session-import' import type { Driver } from '@ari/providers/driver' import { AriCoreDriver } from '@ari/ari-core/driver' +import { McpServerStore, mergeCoreMcp, publicMcpServer } from '@ari/ari-core/mcp-servers' +import { sanitizeMcpSegment } from '@ari/ari-core/mcp-tools' +import { listAriCoreSkills, readTrustedSkillRoots, setWorkspaceSkillTrust } from '@ari/ari-core/skills' +import { BUILT_IN_TOOLS } from '@ari/ari-core/tools' import { FileConversationStore } from '@ari/ari-core/conversation-store' import type { McpServerConfig } from '@ari/ari-core/mcp-servers' import { todoFilenameFor } from '@ari/ari-core/todo' @@ -590,14 +595,28 @@ export function registerRpc(contents: WebContents, options: RegisterRpcOptions = // The registry exists immediately with Ari Core attached; CLI drivers are // added as background detection completes. Nothing waits on that to answer. const driverRegistry = new DriverRegistry() + const coreDir = join(app.getPath('userData'), 'ari-core') + const mcpStore = new McpServerStore({ dir: coreDir }) + let mcpStoreReady: Promise | null = null + const ensureMcpStore = (): Promise => { + mcpStoreReady ??= mcpStore.load().then(() => undefined) + return mcpStoreReady + } + void ensureMcpStore() driverRegistry.register( new AriCoreDriver(getEndpointStore(), { // Ari Core owns its transcript (it has no provider-side thread to // resume), so conversation memory is persisted per session on disk. - conversations: new FileConversationStore( - join(app.getPath('userData'), 'ari-core', 'conversations'), - ), - mcpServers: () => browserCoreMcpServers, + conversations: new FileConversationStore(join(coreDir, 'conversations')), + skills: { homeDir: homedir(), trustDir: coreDir }, + mcpServers: () => { + for (const server of mcpStore.list()) { + if (!server.disabled && sanitizeMcpSegment(server.name) === 'ari_browser') { + log.warn('mcp server name is reserved', { server: server.name, reason: 'reserved-name' }) + } + } + return mergeCoreMcp(mcpStore.list(), browserCoreMcpServers) + }, }), ) driverRegistryRef = driverRegistry @@ -1166,6 +1185,67 @@ export function registerRpc(contents: WebContents, options: RegisterRpcOptions = writeProviderConfig(params.kind, params.fileId, params.content), ) + const resolveInventoryWorkspace = async (workspacePath: string | null): Promise => { + if (workspacePath === null) return homedir() + return resolveInsideRoots(resolve(workspacePath), await collectFsRoots()) + } + + r.register('providers.extensionInventory', async (params) => { + const workspace = await resolveInventoryWorkspace(params.workspacePath) + if (params.kind === 'ari-core') { + await ensureMcpStore() + const trusted = await readTrustedSkillRoots(coreDir) + const skills = await listAriCoreSkills(workspace, trusted, { + homeDir: homedir(), + builtInNames: BUILT_IN_TOOLS.map((tool) => tool.name), + }) + return buildExtensionInventory({ + kind: 'ari-core', + workspacePath: workspace, + coreServers: mcpStore.list(), + coreSkills: skills, + }) + } + return buildExtensionInventory({ kind: params.kind, workspacePath: workspace }) + }) + + r.register('providers.readExtensionFile', async (params) => { + const workspace = await resolveInventoryWorkspace(params.workspacePath) + if (params.kind === 'ari-core') await ensureMcpStore() + const inventory = + params.kind === 'ari-core' + ? await buildExtensionInventory({ + kind: 'ari-core', + workspacePath: workspace, + coreServers: mcpStore.list(), + coreSkills: await listAriCoreSkills(workspace, await readTrustedSkillRoots(coreDir), { + homeDir: homedir(), + builtInNames: BUILT_IN_TOOLS.map((tool) => tool.name), + }), + }) + : await buildExtensionInventory({ kind: params.kind, workspacePath: workspace }) + return readInventoriedSkill(inventory, params.path) + }) + + r.register('ariCore.mcp.list', async () => { + await ensureMcpStore() + return { servers: mcpStore.list().map(publicMcpServer) } + }) + r.register('ariCore.mcp.upsert', async (params) => { + await ensureMcpStore() + const saved = await mcpStore.patch(params) + return publicMcpServer(saved) + }) + r.register('ariCore.mcp.remove', async (params) => { + await ensureMcpStore() + return { removed: await mcpStore.remove(params.id) } + }) + r.register('ariCore.skills.trust', async (params) => { + const workspace = await resolveInsideRoots(resolve(params.workspacePath), await collectFsRoots()) + const trusted = await setWorkspaceSkillTrust(coreDir, workspace, params.trusted) + return { trusted } + }) + // Merged model catalogs per kind: dynamic overlay → snapshot → static. r.register('providers.models', () => { void catalogService.refreshIfStale() diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 77a83fa4..d7e141d5 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -777,6 +777,7 @@ function Shell() { section={settingsSection} onSectionChange={setSettingsSection} onBack={() => setSettingsOpen(false)} + workspacePath={sessionWorkspace?.path ?? null} onOpenTerminal={() => { setSettingsOpen(false) setFullPage(null) diff --git a/apps/desktop/src/renderer/src/features/providers/AgentConfigSettings.test.tsx b/apps/desktop/src/renderer/src/features/providers/AgentConfigSettings.test.tsx index ba5e20e9..ab344a28 100644 --- a/apps/desktop/src/renderer/src/features/providers/AgentConfigSettings.test.tsx +++ b/apps/desktop/src/renderer/src/features/providers/AgentConfigSettings.test.tsx @@ -41,6 +41,7 @@ beforeEach(() => { if (method === 'providers.writeConfig') return { ok: true, bytesWritten: 12 } // The pi page also offers session import. if (method === 'sessions.importable') return [] + if (method === 'providers.extensionInventory') return { records: [], truncated: false } throw new Error(`unexpected ${method}`) }) }) @@ -89,6 +90,7 @@ describe('AgentConfigSettings', () => { return { content: '{}', exists: true, path: 'x', truncated: false } } if (method === 'sessions.importable') return [] + if (method === 'providers.extensionInventory') return { records: [], truncated: false } return { ok: false, error: 'settings.json is not valid JSON: Unexpected end of input' } }) render() @@ -110,9 +112,11 @@ describe('AgentConfigSettings', () => { }) it('says so plainly when Ari has no layout for an agent', async () => { - mocks.invoke.mockImplementation(async (method: string) => - method === 'sessions.importable' ? [] : { dir: null, files: [] }, - ) + mocks.invoke.mockImplementation(async (method: string) => { + if (method === 'sessions.importable') return [] + if (method === 'providers.extensionInventory') return { records: [], truncated: false } + return { dir: null, files: [] } + }) render() expect(await screen.findByText(/no confirmed config layout/i)).toBeInTheDocument() }) diff --git a/apps/desktop/src/renderer/src/features/providers/AgentConfigSettings.tsx b/apps/desktop/src/renderer/src/features/providers/AgentConfigSettings.tsx index baabe13b..05867ceb 100644 --- a/apps/desktop/src/renderer/src/features/providers/AgentConfigSettings.tsx +++ b/apps/desktop/src/renderer/src/features/providers/AgentConfigSettings.tsx @@ -8,6 +8,7 @@ import { Textarea } from '@ari/ui/textarea' import { createLogger } from '@ari/shared/logger' import { SettingsPage } from '../settings/SettingsPage' import { SessionImport } from './SessionImport' +import { ExtensionInventory } from './ExtensionInventory' import { rpc } from '../../lib/rpc' const log = createLogger('ui:agent-config') @@ -19,7 +20,7 @@ type ConfigFile = RpcResults['providers.configFiles']['files'][number] * process is the authority — an agent with no mapping answers with an empty * file list — but the picker needs names before any call returns. */ -const CONFIGURABLE: DriverKind[] = ['pi', 'claude', 'codex', 'opencode', 'grok'] +const CONFIGURABLE: DriverKind[] = ['pi', 'claude', 'codex', 'opencode', 'grok', 'ari-core'] const KIND_LABELS: Partial> = { pi: 'pi', @@ -27,6 +28,7 @@ const KIND_LABELS: Partial> = { codex: 'Codex', opencode: 'OpenCode', grok: 'Grok', + 'ari-core': 'Ari Core', } /** @@ -40,7 +42,7 @@ const KIND_LABELS: Partial> = { * refuses a JSON file it could not parse, which is the one mistake that loses * an agent's configuration silently. */ -export function AgentConfigSettings() { +export function AgentConfigSettings({ workspacePath = null }: { workspacePath?: string | null }) { const [kind, setKind] = useState('pi') const [dir, setDir] = useState(null) const [files, setFiles] = useState([]) @@ -156,7 +158,7 @@ export function AgentConfigSettings() { {loading ? ( - ) : files.length === 0 ? ( + ) : files.length === 0 && kind !== 'ari-core' ? (

Ari has no confirmed config layout for this agent, so it will not guess at a path.

@@ -230,6 +232,8 @@ export function AgentConfigSettings() { ) : null} + + {kind === 'pi' ? : null} ) diff --git a/apps/desktop/src/renderer/src/features/providers/ExtensionInventory.tsx b/apps/desktop/src/renderer/src/features/providers/ExtensionInventory.tsx new file mode 100644 index 00000000..9ab6dbfb --- /dev/null +++ b/apps/desktop/src/renderer/src/features/providers/ExtensionInventory.tsx @@ -0,0 +1,270 @@ +import { useCallback, useEffect, useState } from 'react' +import type { DriverKind } from '@ari/contracts/common' +import type { RpcResults } from '@ari/contracts/rpc' +import { Badge } from '@ari/ui/badge' +import { Button } from '@ari/ui/button' +import { Spinner } from '@ari/ui/spinner' +import { rpc } from '../../lib/rpc' + +type RecordRow = RpcResults['providers.extensionInventory']['records'][number] +type PublicServer = RpcResults['ariCore.mcp.list']['servers'][number] + +const DELIVERY: Record = { + delegated: 'Loaded by the agent', + injected: 'Ari browser', + hosted: 'Ari Core', +} + +const PROBLEM: Record, string> = { + 'missing-binary': 'missing binary', + unreadable: 'unreadable', + 'duplicate-name': 'name collides with ari-browser', + 'untrusted-project': 'not trusted', +} + +/** + * Skills and MCP servers the selected agent will see. Pass-through rows are + * read-only. Ari Core rows can be toggled; env values are write-only. + */ +export function ExtensionInventory({ + kind, + workspacePath, +}: { + kind: DriverKind + workspacePath: string | null +}) { + const [records, setRecords] = useState([]) + const [truncated, setTruncated] = useState(false) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [preview, setPreview] = useState(null) + const [servers, setServers] = useState([]) + + const refresh = useCallback(async () => { + setLoading(true) + setError(null) + try { + const listed = await rpc.invoke('providers.extensionInventory', { kind, workspacePath }) + setRecords(listed.records ?? []) + setTruncated(listed.truncated ?? false) + if (kind === 'ari-core') { + const mcp = await rpc.invoke('ariCore.mcp.list') + setServers(mcp.servers) + } else { + setServers([]) + } + } catch (caught: unknown) { + setRecords([]) + setError(caught instanceof Error ? caught.message : String(caught)) + } finally { + setLoading(false) + } + }, [kind, workspacePath]) + + useEffect(() => { + void refresh() + }, [refresh]) + + const untrusted = records.filter((row) => row.problem === 'untrusted-project').length + + return ( +
+

+ Skills and MCP +

+

+ {kind === 'ari-core' + ? 'Ari Core loads these itself. User skills apply immediately on the next message. Project skills stay off until you trust this folder.' + : 'Claude Code and Codex load these themselves. Ari only adds the in-app browser. Changes show up on the next message. If a resumed chat still lacks a server, start a new chat.'} +

+ {loading ? : null} + {error ? ( +

+ {error} +

+ ) : null} + {truncated ?

The list is truncated.

: null} + {!loading && records.length === 0 ? ( +

No skills or MCP servers found for this agent.

+ ) : ( +
    + {records.map((row) => ( +
  • +
    + {row.name} + {row.kind} + {row.scope} + + {row.provider === 'claude' + ? 'Loaded by Claude' + : row.provider === 'codex' + ? 'Loaded by Codex' + : DELIVERY[row.delivery]} + + {row.disabled ? disabled : null} + {row.problem ? {PROBLEM[row.problem]} : null} +
    + {row.summary ?

    {row.summary}

    : null} + {row.command ? ( +

    {row.command}

    + ) : null} + {row.problem === 'duplicate-name' ? ( +

    + This name collides with the in-app browser. Ari still sends its own ari-browser. +

    + ) : null} +
    + {row.kind === 'skill' && row.sourcePath ? ( + + ) : null} + {kind === 'ari-core' && row.kind === 'mcp' && row.scope === 'user' ? ( + + ) : null} +
    +
  • + ))} +
+ )} + {preview ? ( +
+          {preview}
+        
+ ) : null} + {kind === 'ari-core' && untrusted > 0 && workspacePath ? ( + + ) : null} + {kind === 'ari-core' ? void refresh()} /> : null} +
+ ) +} + +function CoreMcpForm({ servers, onSaved }: { servers: PublicServer[]; onSaved: () => void }) { + const [name, setName] = useState('') + const [command, setCommand] = useState('') + const [args, setArgs] = useState('') + const [env, setEnv] = useState('') + const [error, setError] = useState(null) + + return ( +
{ + event.preventDefault() + const envRecord: Record = {} + for (const line of env.split('\n')) { + const eq = line.indexOf('=') + if (eq <= 0) continue + envRecord[line.slice(0, eq).trim()] = line.slice(eq + 1) + } + void rpc + .invoke('ariCore.mcp.upsert', { + name: name.trim(), + command: command.trim(), + args: args.split('\n').map((line) => line.trim()).filter((line) => line.length > 0), + ...(env.trim().length > 0 ? { env: envRecord } : {}), + }) + .then(() => { + setName('') + setCommand('') + setArgs('') + setEnv('') + setError(null) + onSaved() + }) + .catch((caught: unknown) => setError(caught instanceof Error ? caught.message : String(caught))) + }} + > +

Add an Ari Core MCP server

+

+ {servers.length} saved. Env values are stored and never shown again. Leave env blank to keep + existing values when you disable a server above. +

+ + +