Files
PiAgentUI/extensions/piagentui-extension-manager.ts
T
Evandro b3489e1ede
Build & Push Frontend Docker Image / build-and-push (push) Canceled after 0s
Build Validation / validate (push) Canceled after 6s
Build & Push Backend Docker Image / build-and-push (push) Failing after 10m10s
Build & Push Gateway Docker Image / build-and-push (push) Failing after 10m9s
🎉 Initial commit: PiAgentUI — AI Agent Desktop & Web UI
A professional workspace for AI coding agents with:
- Real-time streaming chat with SSE
- Session management (fork, clone, tree navigation)
- MCP tools browser & metadata panel
- Skills browser (global, project, package sources)
- Voice input & STT provider configuration
- Integrated terminal & file explorer
- Model selector with capability metadata
- Keyboard-first UI with command palette
- 8 languages (en, pt-BR, es, ja, zh-CN, ar, bn, hi)
- Tauri 2 desktop app
- React 19 + TypeScript + Vite 8 + Tailwind CSS 4
- Pi Agent extension runtime

Originally forked from OpenCodeUI, now an independent project.
License: GPL-3.0-only
2026-06-01 00:50:06 -03:00

514 lines
16 KiB
TypeScript

import { spawn } from 'node:child_process'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
export type ExtensionPackageScope = 'user' | 'project'
export type ExtensionPackageStatus = 'active' | 'inactive' | 'missing' | 'error'
export type ExtensionResourceType = 'extensions' | 'skills' | 'prompts' | 'themes'
export interface ExtensionResource {
name: string
path: string
enabled: boolean
}
export interface ExtensionPackageResources {
extensions: ExtensionResource[]
skills: ExtensionResource[]
prompts: ExtensionResource[]
themes: ExtensionResource[]
}
export interface ExtensionPackageInfo {
source: string
scope: ExtensionPackageScope
filtered: boolean
installedPath?: string
packageName?: string
version?: string
description?: string
image?: string
keywords?: string[]
status: ExtensionPackageStatus
error?: string
resources: ExtensionPackageResources
}
export interface ExtensionCatalogSummary {
total: number
active: number
inactive: number
missing: number
error: number
}
export interface ExtensionCatalog {
packages: ExtensionPackageInfo[]
summary: ExtensionCatalogSummary
}
export interface BuildExtensionCatalogOptions {
cwd: string
homeDir?: string
}
interface PiSettings {
packages?: PackageSourceEntry[]
}
interface PackageSourceObject {
source: string
extensions?: string[]
skills?: string[]
prompts?: string[]
themes?: string[]
}
type PackageSourceEntry = string | PackageSourceObject
interface NormalizedPackageSource {
source: string
scope: ExtensionPackageScope
filtered: boolean
filters: Partial<Record<ExtensionResourceType, string[]>>
}
interface PackageJsonMetadata {
name?: string
version?: string
description?: string
keywords?: string[]
pi?: Partial<Record<ExtensionResourceType, string[]>> & { image?: string }
}
export interface ParsedPiInstallCommand {
source: string
local: boolean
}
export type PiCommandRunner = (args: string[], options: { cwd: string }) => Promise<{ stdout: string; stderr: string }>
const RESOURCE_TYPES: ExtensionResourceType[] = ['extensions', 'skills', 'prompts', 'themes']
const RESOURCE_FILE_PATTERNS: Record<ExtensionResourceType, RegExp> = {
extensions: /\.(?:ts|js)$/,
skills: /\.md$/,
prompts: /\.md$/,
themes: /\.json$/,
}
function getHomeDir(homeDir?: string): string {
return homeDir || process.env.USERPROFILE || process.env.HOME || os.homedir()
}
function getAgentDir(homeDir?: string): string {
return path.join(getHomeDir(homeDir), '.pi', 'agent')
}
function readJsonFile<T>(filePath: string): T | null {
if (!fs.existsSync(filePath)) return null
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T
} catch {
return null
}
}
function readSettings(filePath: string): PiSettings {
return readJsonFile<PiSettings>(filePath) ?? {}
}
function normalizePackageEntry(
entry: PackageSourceEntry,
scope: ExtensionPackageScope,
): NormalizedPackageSource | null {
if (typeof entry === 'string') {
const source = entry.trim()
return source ? { source, scope, filtered: false, filters: {} } : null
}
if (!entry || typeof entry.source !== 'string') return null
const source = entry.source.trim()
if (!source) return null
return {
source,
scope,
filtered: true,
filters: {
extensions: entry.extensions,
skills: entry.skills,
prompts: entry.prompts,
themes: entry.themes,
},
}
}
function parseNpmPackageName(source: string): string | null {
if (!source.startsWith('npm:')) return null
const spec = source.slice('npm:'.length).trim()
const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@.+)?$/)
return (match?.[1] ?? spec) || null
}
function isProbablyLocalSource(source: string): boolean {
return (
source.startsWith('.') ||
source.startsWith('~') ||
path.isAbsolute(source) ||
/^[a-zA-Z]:[\\/]/.test(source) ||
source.includes('\\')
)
}
function resolveLocalSource(source: string, scope: ExtensionPackageScope, cwd: string, homeDir?: string): string {
const baseDir = scope === 'project' ? path.join(cwd, '.pi') : getAgentDir(homeDir)
if (source.startsWith('~')) return path.resolve(path.join(getHomeDir(homeDir), source.slice(1)))
if (path.isAbsolute(source) || /^[a-zA-Z]:[\\/]/.test(source)) return path.resolve(source)
return path.resolve(baseDir, source)
}
function parseGitInstallPathParts(source: string): { host: string; repoPath: string } | null {
const normalized = source.replace(/^git:/, '')
const githubShorthand = normalized.match(/^github\.com[/:]([^\s]+)$/)
if (githubShorthand) return { host: 'github.com', repoPath: githubShorthand[1].replace(/\.git$/, '') }
const sshMatch = normalized.match(/^git@([^:]+):(.+)$/)
if (sshMatch) return { host: sshMatch[1], repoPath: sshMatch[2].replace(/\.git$/, '') }
try {
const url = new URL(normalized)
if (!url.hostname || !url.pathname) return null
return { host: url.hostname, repoPath: url.pathname.replace(/^\//, '').replace(/\.git$/, '') }
} catch {
return null
}
}
function resolveInstalledPath(
source: string,
scope: ExtensionPackageScope,
cwd: string,
homeDir?: string,
): string | undefined {
const agentDir = getAgentDir(homeDir)
if (source.startsWith('npm:')) {
const packageName = parseNpmPackageName(source)
if (!packageName) return undefined
const installRoot = scope === 'project' ? path.join(cwd, '.pi', 'npm') : path.join(agentDir, 'npm')
const packagePath = path.join(installRoot, 'node_modules', ...packageName.split('/'))
return fs.existsSync(packagePath) ? packagePath : undefined
}
if (isProbablyLocalSource(source)) {
const packagePath = resolveLocalSource(source, scope, cwd, homeDir)
return fs.existsSync(packagePath) ? packagePath : undefined
}
const gitParts = parseGitInstallPathParts(source)
if (gitParts) {
const installRoot = scope === 'project' ? path.join(cwd, '.pi', 'git') : path.join(agentDir, 'git')
const packagePath = path.join(installRoot, gitParts.host, ...gitParts.repoPath.split(/[\\/]+/))
return fs.existsSync(packagePath) ? packagePath : undefined
}
return undefined
}
function collectFiles(root: string, resourceType: ExtensionResourceType): string[] {
if (!fs.existsSync(root)) return []
const stat = fs.statSync(root)
if (stat.isFile()) return RESOURCE_FILE_PATTERNS[resourceType].test(root) ? [root] : []
if (!stat.isDirectory()) return []
const files: string[] = []
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const entryPath = path.join(root, entry.name)
if (entry.isDirectory()) {
files.push(...collectFiles(entryPath, resourceType))
} else if (entry.isFile() && RESOURCE_FILE_PATTERNS[resourceType].test(entryPath)) {
files.push(entryPath)
}
}
return files.sort((a, b) => a.localeCompare(b))
}
function stripOverridePatterns(entries: string[]): string[] {
return entries.filter(entry => !entry.trim().startsWith('!'))
}
function resolveManifestEntry(packageRoot: string, entry: string, resourceType: ExtensionResourceType): string[] {
const normalized = entry.trim()
if (!normalized || normalized.startsWith('!')) return []
if (normalized.includes('*')) {
const conventionRoot = path.join(packageRoot, resourceType)
return collectFiles(conventionRoot, resourceType)
}
const entryPath = path.resolve(packageRoot, normalized)
return collectFiles(entryPath, resourceType)
}
function discoverResourcePaths(
packageRoot: string,
metadata: PackageJsonMetadata | null,
resourceType: ExtensionResourceType,
): string[] {
const manifestEntries = metadata?.pi?.[resourceType]
if (manifestEntries && manifestEntries.length > 0) {
const files = stripOverridePatterns(manifestEntries).flatMap(entry =>
resolveManifestEntry(packageRoot, entry, resourceType),
)
return Array.from(new Set(files)).sort((a, b) => a.localeCompare(b))
}
return collectFiles(path.join(packageRoot, resourceType), resourceType)
}
function toRelativePosix(packageRoot: string, filePath: string): string {
return path.relative(packageRoot, filePath).replace(/\\/g, '/')
}
function wildcardToRegExp(pattern: string): RegExp {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
return new RegExp(`^${escaped}$`)
}
function matchesFilter(packageRoot: string, filePath: string, patterns: string[]): boolean {
const relativePath = toRelativePosix(packageRoot, filePath)
const normalizedPatterns = patterns.map(pattern => pattern.trim()).filter(Boolean)
if (normalizedPatterns.length === 0) return false
return normalizedPatterns.some(pattern => {
const positivePattern = pattern.startsWith('!') ? pattern.slice(1) : pattern
if (!positivePattern) return false
const normalizedPattern = positivePattern.replace(/\\/g, '/')
return (
relativePath === normalizedPattern ||
relativePath.endsWith(`/${normalizedPattern}`) ||
wildcardToRegExp(normalizedPattern).test(relativePath)
)
})
}
function resourceName(filePath: string): string {
if (path.basename(filePath).toLowerCase() === 'skill.md') return path.basename(path.dirname(filePath))
return path.basename(filePath)
}
function buildResources(
packageRoot: string,
metadata: PackageJsonMetadata | null,
filters: Partial<Record<ExtensionResourceType, string[]>>,
): ExtensionPackageResources {
const resources = {} as ExtensionPackageResources
for (const resourceType of RESOURCE_TYPES) {
const files = discoverResourcePaths(packageRoot, metadata, resourceType)
const patterns = filters[resourceType]
resources[resourceType] = files.map(filePath => ({
name: resourceName(filePath),
path: filePath,
enabled: patterns === undefined ? true : matchesFilter(packageRoot, filePath, patterns),
}))
}
return resources
}
function hasEnabledResource(resources: ExtensionPackageResources): boolean {
return RESOURCE_TYPES.some(resourceType => resources[resourceType].some(resource => resource.enabled))
}
function countByStatus(packages: ExtensionPackageInfo[]): ExtensionCatalogSummary {
return packages.reduce<ExtensionCatalogSummary>(
(summary, pkg) => {
summary.total += 1
summary[pkg.status] += 1
return summary
},
{ total: 0, active: 0, inactive: 0, missing: 0, error: 0 },
)
}
function readPackageMetadata(packageRoot: string): { metadata: PackageJsonMetadata | null; error?: string } {
const packageJsonPath = path.join(packageRoot, 'package.json')
if (!fs.existsSync(packageJsonPath)) return { metadata: null }
try {
return { metadata: JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as PackageJsonMetadata }
} catch (error) {
return { metadata: null, error: error instanceof Error ? error.message : String(error) }
}
}
export function buildExtensionCatalog(options: BuildExtensionCatalogOptions): ExtensionCatalog {
const homeDir = getHomeDir(options.homeDir)
const agentDir = getAgentDir(homeDir)
const userSettings = readSettings(path.join(agentDir, 'settings.json'))
const projectSettings = readSettings(path.join(options.cwd, '.pi', 'settings.json'))
const configuredPackages = [
...(userSettings.packages ?? []).map(entry => normalizePackageEntry(entry, 'user')),
...(projectSettings.packages ?? []).map(entry => normalizePackageEntry(entry, 'project')),
].filter((entry): entry is NormalizedPackageSource => entry !== null)
const packages = configuredPackages.map<ExtensionPackageInfo>(entry => {
const installedPath = resolveInstalledPath(entry.source, entry.scope, options.cwd, homeDir)
if (!installedPath) {
return {
source: entry.source,
scope: entry.scope,
filtered: entry.filtered,
status: 'missing',
resources: { extensions: [], skills: [], prompts: [], themes: [] },
}
}
const { metadata, error } = readPackageMetadata(installedPath)
const resources = buildResources(installedPath, metadata, entry.filters)
const status: ExtensionPackageStatus = error ? 'error' : hasEnabledResource(resources) ? 'active' : 'inactive'
return {
source: entry.source,
scope: entry.scope,
filtered: entry.filtered,
installedPath,
packageName: metadata?.name,
version: metadata?.version,
description: metadata?.description,
image: metadata?.pi?.image,
keywords: metadata?.keywords,
status,
error,
resources,
}
})
packages.sort((a, b) => {
if (a.scope !== b.scope) return a.scope === 'project' ? -1 : 1
return (a.packageName || a.source).localeCompare(b.packageName || b.source)
})
return { packages, summary: countByStatus(packages) }
}
function tokenizeCommand(command: string): string[] {
const tokens: string[] = []
let current = ''
let quote: '"' | "'" | null = null
for (let index = 0; index < command.length; index += 1) {
const char = command[index]
if (quote) {
if (char === quote) {
quote = null
} else {
current += char
}
continue
}
if (char === '"' || char === "'") {
quote = char
continue
}
if (/\s/.test(char)) {
if (current) {
tokens.push(current)
current = ''
}
continue
}
current += char
}
if (quote) throw new Error('Unterminated quote in install command')
if (current) tokens.push(current)
return tokens
}
export function parsePiInstallCommand(command: string): ParsedPiInstallCommand {
const tokens = tokenizeCommand(command.trim())
if (
tokens.length < 2 ||
path
.basename(tokens[0])
.replace(/\.(cmd|exe)$/i, '')
.toLowerCase() !== 'pi'
) {
throw new Error('Paste a pi install command, for example: pi install npm:pi-hud')
}
if (tokens[1] !== 'install') {
throw new Error('Only pi install commands are supported')
}
let local = false
let source: string | null = null
for (const token of tokens.slice(2)) {
if (token === '-l' || token === '--local') {
local = true
continue
}
if (token.startsWith('-')) throw new Error(`Unsupported install option: ${token}`)
if (source) throw new Error(`Unsupported extra argument: ${token}`)
if (/[;&|<>`]/.test(token)) throw new Error(`Unsupported shell syntax in install command: ${token}`)
source = token
}
if (!source) throw new Error('Missing extension source in pi install command')
return { source, local }
}
function getPiExecutable(): string {
return process.platform === 'win32' ? 'pi.cmd' : 'pi'
}
export function defaultPiCommandRunner(
args: string[],
options: { cwd: string },
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn(getPiExecutable(), args, { cwd: options.cwd, shell: false })
let stdout = ''
let stderr = ''
child.stdout?.on('data', chunk => {
stdout += String(chunk)
})
child.stderr?.on('data', chunk => {
stderr += String(chunk)
})
child.on('error', reject)
child.on('close', code => {
if (code === 0) {
resolve({ stdout, stderr })
return
}
reject(new Error(stderr.trim() || `pi ${args.join(' ')} exited with code ${code ?? 'unknown'}`))
})
})
}
export async function executeExtensionInstallCommand(
command: string,
options: { cwd: string; runner?: PiCommandRunner },
): Promise<{ stdout: string; stderr: string; source: string; scope: ExtensionPackageScope }> {
const parsed = parsePiInstallCommand(command)
const runner = options.runner ?? defaultPiCommandRunner
const args = ['install', parsed.source, ...(parsed.local ? ['-l'] : [])]
const result = await runner(args, { cwd: options.cwd })
return { ...result, source: parsed.source, scope: parsed.local ? 'project' : 'user' }
}
export async function executeExtensionPackageRemoval(
target: { source: string; scope: ExtensionPackageScope },
options: { cwd: string; runner?: PiCommandRunner },
): Promise<{ stdout: string; stderr: string }> {
const runner = options.runner ?? defaultPiCommandRunner
return runner(['remove', target.source, ...(target.scope === 'project' ? ['-l'] : [])], { cwd: options.cwd })
}