Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions apps/desktop/src/main/extension-inventory.ts
Original file line number Diff line number Diff line change
@@ -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<RpcResults['providers.extensionInventory']> {
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<RpcResults['providers.readExtensionFile']> {
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 } : {}),
}))
}
6 changes: 6 additions & 0 deletions apps/desktop/src/main/ipc-methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
88 changes: 84 additions & 4 deletions apps/desktop/src/main/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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<void> | null = null
const ensureMcpStore = (): Promise<void> => {
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
Expand Down Expand Up @@ -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<string> => {
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()
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,7 @@ function Shell() {
section={settingsSection}
onSectionChange={setSettingsSection}
onBack={() => setSettingsOpen(false)}
workspacePath={sessionWorkspace?.path ?? null}
onOpenTerminal={() => {
setSettingsOpen(false)
setFullPage(null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
})
})
Expand Down Expand Up @@ -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(<AgentConfigSettings />)
Expand All @@ -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(<AgentConfigSettings />)
expect(await screen.findByText(/no confirmed config layout/i)).toBeInTheDocument()
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -19,14 +20,15 @@ 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<Record<DriverKind, string>> = {
pi: 'pi',
claude: 'Claude Code',
codex: 'Codex',
opencode: 'OpenCode',
grok: 'Grok',
'ari-core': 'Ari Core',
}

/**
Expand All @@ -40,7 +42,7 @@ const KIND_LABELS: Partial<Record<DriverKind, string>> = {
* 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<DriverKind>('pi')
const [dir, setDir] = useState<string | null>(null)
const [files, setFiles] = useState<ConfigFile[]>([])
Expand Down Expand Up @@ -156,7 +158,7 @@ export function AgentConfigSettings() {
</h2>
{loading ? (
<Spinner size="sm" />
) : files.length === 0 ? (
) : files.length === 0 && kind !== 'ari-core' ? (
<p className="text-sm text-fg-muted">
Ari has no confirmed config layout for this agent, so it will not guess at a path.
</p>
Expand Down Expand Up @@ -230,6 +232,8 @@ export function AgentConfigSettings() {
</section>
) : null}

<ExtensionInventory kind={kind} workspacePath={workspacePath} />

{kind === 'pi' ? <SessionImport /> : null}
</SettingsPage>
)
Expand Down
Loading
Loading