Skip to content
Open
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
6 changes: 3 additions & 3 deletions apps/electron/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@proma/electron",
"version": "0.8.1",
"version": "0.8.3",
"description": "Proma next gen ai software with general agents - Electron App",
"main": "dist/main.cjs",
"author": {
Expand All @@ -13,8 +13,8 @@
"dev": "concurrently -k -n vite,electron -c blue,green \"bun run dev:vite\" \"bun run dev:electron\"",
"dev:split": "bash scripts/dev-split.sh",
"dev:vite": "vite dev",
"dev:kill": "pkill -f 'electronmon \\.' 2>/dev/null; pkill -f 'electron.*dist/main' 2>/dev/null; sleep 0.5; true",
"dev:electron": "bun run dev:kill && bun run build:main && bun run build:preload && bun run build:resources && sleep 2 && concurrently -k -n main,preload,app -c yellow,magenta,cyan \"bun run watch:main\" \"bun run watch:preload\" \"bunx electronmon .\"",
"dev:kill": "pkill -f 'electronmon \\.' 2>/dev/null; pkill -f 'electron.*dist/main' 2>/dev/null; node -e \"setTimeout(() => process.exit(0), 500)\"; true",
"dev:electron": "bun run dev:kill && bun run build:main && bun run build:preload && bun run build:resources && node -e \"setTimeout(() => process.exit(0), 2000)\" && concurrently -k -n main,preload,app -c yellow,magenta,cyan \"bun run watch:main\" \"bun run watch:preload\" \"bunx electronmon .\"",
"build:main": "esbuild src/main/index.ts --bundle --platform=node --format=cjs --outfile=dist/main.cjs --external:electron --external:@anthropic-ai/claude-agent-sdk",
"build:preload": "esbuild src/preload/index.ts --bundle --platform=node --format=cjs --outfile=dist/preload.cjs --external:electron",
"watch:main": "esbuild src/main/index.ts --bundle --platform=node --format=cjs --outfile=dist/main.cjs --external:electron --external:@anthropic-ai/claude-agent-sdk --watch=forever",
Expand Down
18 changes: 18 additions & 0 deletions apps/electron/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,11 @@ import {
getWorkspaceMcpConfig,
saveWorkspaceMcpConfig,
getAllWorkspaceSkills,
getGlobalSkills,
getWorkspaceCapabilities,
getAgentWorkspace,
deleteWorkspaceSkill,
installGlobalSkill,
toggleWorkspaceSkill,
getWorkspacePermissionMode,
setWorkspacePermissionMode,
Expand Down Expand Up @@ -896,6 +898,14 @@ export function registerIpcHandlers(): void {
}
)

// 获取全局 Skill 列表(~/.proma/default-skills/)
ipcMain.handle(
AGENT_IPC_CHANNELS.GET_GLOBAL_SKILLS,
async (): Promise<SkillMeta[]> => {
return getGlobalSkills()
}
)

// 获取工作区 Skills 目录绝对路径
ipcMain.handle(
AGENT_IPC_CHANNELS.GET_SKILLS_DIR,
Expand All @@ -920,6 +930,14 @@ export function registerIpcHandlers(): void {
}
)

// 从全局 Skill 安装到工作区
ipcMain.handle(
AGENT_IPC_CHANNELS.INSTALL_GLOBAL_SKILL,
async (_, workspaceSlug: string, skillSlug: string): Promise<SkillMeta> => {
return installGlobalSkill(workspaceSlug, skillSlug)
}
)

// 发送 Agent 消息(触发 Agent SDK 流式响应)
ipcMain.handle(
AGENT_IPC_CHANNELS.SEND_MESSAGE,
Expand Down
8 changes: 8 additions & 0 deletions apps/electron/src/main/lib/agent-prompt-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,14 @@ Agent 工具支持 \`model\` 参数(可选值:\`sonnet\` / \`opus\` / \`haik
- 当前会话目录(cwd): ~/.proma/agent-workspaces/${ctx.workspaceSlug}/${ctx.sessionId}/
- MCP 配置: ~/.proma/agent-workspaces/${ctx.workspaceSlug}/mcp.json(顶层 key 是 \`servers\`)
- Skills 目录: ~/.proma/agent-workspaces/${ctx.workspaceSlug}/skills/
- 全局 Skill 仓库: ~/.proma/default-skills/

### Skill 安装约定

- 在当前工作区里新建的 Skill 会立刻可用
- Proma 会自动把当前工作区中新建的 Skill 同步到全局 Skill 仓库
- 从全局仓库复制到工作区的 Skill 默认视为局部副本,不应自动反向覆盖全局
- 如果要安装一个全新的 Skill,优先让它先落到当前工作区,再由系统自动同步到全局

### .context 目录层级

Expand Down
161 changes: 157 additions & 4 deletions apps/electron/src/main/lib/agent-workspace-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
getInactiveSkillsDir,
getDefaultSkillsDir,
parseSkillVersion,
SKILL_STORAGE_META_FILE,
} from './config-paths'
import type { AgentWorkspace, McpServerEntry, WorkspaceMcpConfig, SkillMeta, WorkspaceCapabilities, PromaPermissionMode } from '@proma/shared'
import { migratePermissionMode } from '@proma/shared'
Expand All @@ -36,6 +37,48 @@ interface AgentWorkspacesIndex {
/** 当前索引版本 */
const INDEX_VERSION = 2

interface SkillStorageMeta {
version: 1
sourceType: 'bundled' | 'workspace-authored'
sourceWorkspaceSlug?: string
syncedAt: number
}

function readSkillStorageMeta(skillDir: string): SkillStorageMeta | null {
const metaPath = join(skillDir, SKILL_STORAGE_META_FILE)
if (!existsSync(metaPath)) return null

try {
const raw = readFileSync(metaPath, 'utf-8')
const parsed = JSON.parse(raw) as Partial<SkillStorageMeta>
if (parsed.version !== 1) return null
if (parsed.sourceType !== 'bundled' && parsed.sourceType !== 'workspace-authored') return null

return {
version: 1,
sourceType: parsed.sourceType,
sourceWorkspaceSlug: parsed.sourceWorkspaceSlug,
syncedAt: typeof parsed.syncedAt === 'number' ? parsed.syncedAt : Date.now(),
}
} catch (error) {
console.warn('[Agent 工作区] 读取 Skill 元数据失败:', skillDir, error)
return null
}
}

function writeSkillStorageMeta(skillDir: string, meta: SkillStorageMeta): void {
writeFileSync(join(skillDir, SKILL_STORAGE_META_FILE), JSON.stringify(meta, null, 2), 'utf-8')
}

function createWorkspaceAuthoredMeta(workspaceSlug: string): SkillStorageMeta {
return {
version: 1,
sourceType: 'workspace-authored',
sourceWorkspaceSlug: workspaceSlug,
syncedAt: Date.now(),
}
}

/**
* 读取工作区索引文件
*
Expand Down Expand Up @@ -461,11 +504,115 @@ export function getWorkspaceSkills(workspaceSlug: string): SkillMeta[] {
return scanSkillsInDir(getWorkspaceSkillsDir(workspaceSlug), true)
}

export function getGlobalSkills(): SkillMeta[] {
return scanSkillsInDir(getDefaultSkillsDir(), true, true)
}

export function installGlobalSkill(workspaceSlug: string, skillSlug: string): SkillMeta {
const sourcePath = join(getDefaultSkillsDir(), skillSlug)
const activeTargetPath = join(getWorkspaceSkillsDir(workspaceSlug), skillSlug)
const inactiveTargetPath = join(getInactiveSkillsDir(workspaceSlug), skillSlug)

if (!existsSync(sourcePath)) {
throw new Error(`全局 Skill 不存在: ${skillSlug}`)
}

if (existsSync(activeTargetPath) || existsSync(inactiveTargetPath)) {
throw new Error(`Skill 已存在于当前工作区: ${skillSlug}`)
}

cpSync(sourcePath, activeTargetPath, { recursive: true })
console.log(`[Agent 工作区] 已安装全局 Skill: ${workspaceSlug}/${skillSlug}`)

const skillMdPath = join(activeTargetPath, 'SKILL.md')
const content = readFileSync(skillMdPath, 'utf-8')
return parseSkillFrontmatter(content, skillSlug, true, false, readSkillStorageMeta(activeTargetPath))
}

/**
* 将当前工作区中新创建的 Skill 同步到全局仓库
*
* 规则:
* - 当前工作区新建的 Skill:自动写入 ~/.proma/default-skills/
* - 从全局复制到工作区的 Skill:不自动反向覆盖全局
* - 已有其他工作区作为来源的全局 Skill:不跨工作区抢占写入权
*/
export function syncWorkspaceSkillToGlobal(workspaceSlug: string, skillSlug: string): void {
const workspaceSkillPath = join(getWorkspaceSkillsDir(workspaceSlug), skillSlug)
const workspaceSkillMdPath = join(workspaceSkillPath, 'SKILL.md')

if (!existsSync(workspaceSkillPath) || !existsSync(workspaceSkillMdPath)) {
return
}

const workspaceMeta = readSkillStorageMeta(workspaceSkillPath)

// 从全局复制到工作区的 Skill 默认视为局部副本,不自动反向覆盖全局
if (workspaceMeta?.sourceType === 'bundled') {
return
}

if (
workspaceMeta?.sourceType === 'workspace-authored' &&
workspaceMeta.sourceWorkspaceSlug &&
workspaceMeta.sourceWorkspaceSlug !== workspaceSlug
) {
return
}

const globalSkillPath = join(getDefaultSkillsDir(), skillSlug)
const globalMeta = readSkillStorageMeta(globalSkillPath)

if (existsSync(globalSkillPath) && globalMeta?.sourceType === 'bundled') {
console.log(`[Agent 工作区] 跳过同步到全局仓库(内置 Skill 同名): ${workspaceSlug}/${skillSlug}`)
return
}

if (existsSync(globalSkillPath) && !globalMeta && !workspaceMeta) {
console.log(`[Agent 工作区] 跳过同步到全局仓库(全局 Skill 来源未知): ${workspaceSlug}/${skillSlug}`)
return
}

if (
globalMeta?.sourceType === 'workspace-authored' &&
globalMeta.sourceWorkspaceSlug &&
globalMeta.sourceWorkspaceSlug !== workspaceSlug
) {
console.log(`[Agent 工作区] 跳过同步到全局仓库(由其他工作区维护): ${workspaceSlug}/${skillSlug}`)
return
}

if (existsSync(globalSkillPath)) {
rmSync(globalSkillPath, { recursive: true, force: true })
}

cpSync(workspaceSkillPath, globalSkillPath, { recursive: true, force: true })

const syncMeta = createWorkspaceAuthoredMeta(workspaceSlug)
writeSkillStorageMeta(globalSkillPath, syncMeta)
writeSkillStorageMeta(workspaceSkillPath, syncMeta)

console.log(`[Agent 工作区] 已同步 Skill 到全局仓库: ${workspaceSlug}/${skillSlug}`)
}

/**
* 解析 SKILL.md 的 YAML frontmatter
*/
function parseSkillFrontmatter(content: string, slug: string, enabled: boolean): SkillMeta {
const meta: SkillMeta = { slug, name: slug, enabled }
function parseSkillFrontmatter(
content: string,
slug: string,
enabled: boolean,
isGlobal = false,
storageMeta: SkillStorageMeta | null = null,
): SkillMeta {
const meta: SkillMeta = {
slug,
name: slug,
enabled,
...(isGlobal ? { isGlobal: true } : {}),
...(storageMeta?.sourceType ? { sourceType: storageMeta.sourceType } : {}),
...(storageMeta?.sourceWorkspaceSlug ? { sourceWorkspaceSlug: storageMeta.sourceWorkspaceSlug } : {}),
}

const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/)
if (!fmMatch) return meta
Expand Down Expand Up @@ -529,7 +676,7 @@ export function deleteWorkspaceSkill(workspaceSlug: string, skillSlug: string):
*
* 通用扫描逻辑,供 getWorkspaceSkills 和 getAllWorkspaceSkills 复用。
*/
function scanSkillsInDir(dir: string, enabled: boolean): SkillMeta[] {
function scanSkillsInDir(dir: string, enabled: boolean, isGlobal = false): SkillMeta[] {
const skills: SkillMeta[] = []

try {
Expand All @@ -544,7 +691,13 @@ function scanSkillsInDir(dir: string, enabled: boolean): SkillMeta[] {

try {
const content = readFileSync(skillMdPath, 'utf-8')
const meta = parseSkillFrontmatter(content, entry.name, enabled)
const meta = parseSkillFrontmatter(
content,
entry.name,
enabled,
isGlobal,
readSkillStorageMeta(join(dir, entry.name)),
)
skills.push(meta)
} catch {
console.warn(`[Agent 工作区] 解析 Skill 失败: ${entry.name}`)
Expand Down
25 changes: 24 additions & 1 deletion apps/electron/src/main/lib/config-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,19 @@
*/

import { join } from 'node:path'
import { mkdirSync, existsSync, cpSync, readdirSync, readFileSync } from 'node:fs'
import { mkdirSync, existsSync, cpSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'

/** 配置目录名称 */
const CONFIG_DIR_NAME = '.proma'
export const SKILL_STORAGE_META_FILE = '.proma-skill.json'

interface SkillStorageMeta {
version: 1
sourceType: 'bundled' | 'workspace-authored'
sourceWorkspaceSlug?: string
syncedAt: number
}

/**
* 获取配置目录路径
Expand Down Expand Up @@ -383,6 +391,16 @@ function compareSemver(a: string, b: string): number {
return 0
}

function writeBundledSkillMeta(skillDir: string): void {
const meta: SkillStorageMeta = {
version: 1,
sourceType: 'bundled',
syncedAt: Date.now(),
}

writeFileSync(join(skillDir, SKILL_STORAGE_META_FILE), JSON.stringify(meta, null, 2), 'utf-8')
}

/**
* 从 app bundle 同步默认 Skills 到 ~/.proma/default-skills/
*
Expand Down Expand Up @@ -417,6 +435,7 @@ export function seedDefaultSkills(): void {
if (!existsSync(target)) {
// 缺失的 Skill:直接复制
cpSync(source, target, { recursive: true })
writeBundledSkillMeta(target)
console.log(`[配置] 已同步默认 Skill: ${entry.name}`)
} else {
// 已存在:比较版本,bundled 更新时覆盖
Expand All @@ -425,7 +444,11 @@ export function seedDefaultSkills(): void {

if (compareSemver(bundledVer, existingVer) > 0) {
cpSync(source, target, { recursive: true, force: true })
writeBundledSkillMeta(target)
console.log(`[配置] 已升级默认 Skill: ${entry.name} (${existingVer} → ${bundledVer})`)
} else {
// 历史版本可能没有元数据,这里顺手补齐
writeBundledSkillMeta(target)
}
}
}
Expand Down
Loading