🎉 Initial commit: PiAgentUI — AI Agent Desktop & Web UI
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
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
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
This commit is contained in:
commit
b3489e1ede
983 files changed
+158475
No files matched your search
@@ -0,0 +1,513 @@
|
||||
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 })
|
||||
}
|
||||
Reference in new issue
Block a user