diff --git a/README.md b/README.md index 5db325c5..c46725e3 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,10 @@ botmux 不重新实现 Agent 能力,而是直接桥接已有的 AI 编程 CLI ## 功能特性 +### Skill 管理 + +botmux 可以管理 CLI 无关的自定义 Skill Registry,并按 bot 配置在会话启动时优先披露指定 skill;未配置时完全保持 Claude / Codex 等 CLI 自己的默认 skill 行为。安装、bot 级策略、Claude scoped plugin 和 Codex prompt delivery 说明见 [Skill 管理](docs/setup/skills.md)。 + ### 实时流式卡片 每轮对话一张实时更新的飞书卡片,是你在手机/飞书上感知并操控 CLI 的主窗口: diff --git a/docs/setup/skills.md b/docs/setup/skills.md new file mode 100644 index 00000000..98363c80 --- /dev/null +++ b/docs/setup/skills.md @@ -0,0 +1,155 @@ +# Skill 管理 + +botmux 支持一套 CLI 无关的自定义 Skill 管理能力。Skill 包本身只描述“能力是什么、什么时候使用、入口和相对资源在哪里”,不绑定 Claude、Codex 或其他 CLI。botmux 在启动每个会话时按 bot 配置解析出 priority skills,再根据目标 CLI 的能力做投递。 + +## 默认行为 + +没有给某个 bot 配置 `skills` 字段时,botmux 不生成 session manifest,不注入 prompt catalog,不创建 runtime plugin,也不改 CLI 启动参数。底层 CLI 会完全按自己的默认行为加载原生 skill 目录,例如 Codex 继续读取自己的 `~/.codex/skills`,Claude 继续读取自己的 Claude skill/plugin 目录。 + +配置了 `skills` 后,默认语义是“优先披露”,不是“独占隔离”。botmux 会把匹配到的 skill 加入本会话的 priority catalog,并提供 `botmux skill show/read/resources` 给 agent 按需读取。底层 CLI 原本能发现的 skill 仍然由 CLI 自己处理。 + +## Skill 包格式 + +一个 skill 是一个目录,至少包含 `SKILL.md`: + +```text +deploy-runbook/ + SKILL.md + references/ + scripts/ + assets/ +``` + +推荐在 `SKILL.md` 顶部写 frontmatter: + +```markdown +--- +name: deploy-runbook +description: Use when handling production deploys and rollbacks. +version: 1.2.0 +tags: [deploy, sre] +--- + +# Deploy Runbook +``` + +`SKILL.md` 可以引用 `references/`、`scripts/`、`assets/` 等相对路径。agent 读取资源时应使用: + +```bash +botmux skill show deploy-runbook +botmux skill read deploy-runbook references/release.md +botmux skill resources deploy-runbook +``` + +这些命令只在 botmux 会话里可用,依赖本会话的 skill manifest。 + +## 安装 + +本地安装默认复制到 botmux registry,不写入任何 CLI 的全局 skill 目录: + +```bash +botmux skills install ./skills/deploy-runbook +botmux skills install ./skills/deploy-runbook --link +``` + +`--link` 用于开发态,registry 记录原目录;不加 `--link` 会 vendor copy 到 `~/.botmux/skills/store`。 + +Git 仓库安装: + +```bash +botmux skills install git+https://github.com/acme/agent-skills.git --path skills/deploy-runbook +botmux skills install git@github.com:acme/agent-skills.git --path skills/deploy-runbook --ref v1.2.0 +``` + +GitHub 简写: + +```bash +botmux skills install github:acme/agent-skills/skills/deploy-runbook +botmux skills install github:acme/agent-skills --path skills/deploy-runbook --ref main +``` + +私有仓库认证交给系统 Git 凭证、SSH agent 或 `gh auth`。botmux 不保存 GitHub token;带 username/password/token 的 HTTPS Git URL 会被拒绝,避免凭证进入 registry 或 Dashboard。 +Git/GitHub 的 `--path` 必须是仓库内相对路径;绝对路径、`..` segment 或解析到 checkout 外部的 symlink 会被拒绝。 +Git 安装/更新会给底层 Git 命令设置超时,默认 60 秒;需要更长时间时可设置 `BOTMUX_SKILL_GIT_TIMEOUT_MS`。 + +更新、查看和移除: + +```bash +botmux skills list +botmux skills inspect deploy-runbook +botmux skills update deploy-runbook +botmux skills remove deploy-runbook +botmux skills remove deploy-runbook --force +botmux skills doctor +``` + +`remove` 只删除 registry entry 和 botmux 管理的 store 副本,不会自动改写已经配置到 bot 上的引用。CLI 默认会检查 bots.json,发现引用时拒绝删除;确认要保留 dangling policy 时使用 `--force`。Dashboard 会在删除前提示受影响 bot,并把悬挂引用标记为未安装。 + +Git / GitHub 来源需要部署机器安装 `git` 命令;本机目录安装不依赖 git。缺少 git 时 CLI 和 Dashboard job 会返回 `git_not_found`。 + +## Bot Priority Policy + +bot 级配置只表达“这个 bot 优先披露哪些 Skill”。注入方式和是否读取工作区 Skill 都是全局配置,不支持 per-bot override。配置写在 `bots.json` 的 `skills` 字段,也可以通过 `/botconfig set skills ''` 修改: + +```json +{ + "skills": { + "include": ["skill:deploy-runbook"] + } +} +``` + +字段含义: + +- `include`: priority skill 列表,只支持 `skill:`。这些 Skill 会优先披露给该 bot;底层 CLI 原生 Skill 发现机制保持原样。 +- 全局工作区 Skill:`off | all`,决定解析 priority skill 时是否把当前工作区 `.agents/skills` 和 `.botmux/skills` 纳入候选。旧配置里的 `trusted` 会作为 `all` 的兼容别名读取,并在解析诊断里提示 deprecated;当前没有单独的项目 trust store。 +- 全局 delivery:`auto | prompt | native`。`auto` 会优先使用可用 native 投递,否则走 prompt;`native` 在目标 CLI 不支持时会阻止新会话启动并报配置错误。 + +聊天里可以用快捷命令管理当前 bot 的 registry skill: + +```text +/skills +/skills attach deploy-runbook +/skills detach deploy-runbook +``` + +`attach` 只接受已通过 `botmux skills install` 安装的 registry skill。项目内 skill 可通过全局“读取工作区 Skill”开关进入解析候选,但 bot 侧仍只维护 direct priority skill 列表。 + +Dashboard 的 `Skills` 页也提供同一套管理入口: + +- 安装、更新、删除 registry skill(支持本机目录、Git、GitHub 简写)。 +- 设置全局 project skill 默认值和全局 delivery 默认值。 +- 为每个 bot attach/detach 已安装 skill,维护 direct priority skill 列表。 + +Dashboard 的安装/更新会作为后台 job 执行,页面显示处理中状态并轮询结果;慢 Git clone/fetch 不会占住整个 HTTP 请求。 + +## Delivery 行为 + +通用路径是 prompt delivery:botmux 在首轮 prompt 后追加 priority catalog,告诉 agent 先查看这些 skill,并用 `botmux skill show/read/resources` 读取内容。这对 Codex、OpenCode、Gemini、Cursor 等 CLI 都可用,而且不会写入 `~/.codex/skills` 或其他 CLI 全局目录。 + +Claude Code 支持 scoped plugin 优化:botmux 会为当前 session 生成 runtime plugin,并通过 `--plugin-dir` 注入。这个目录是会话派生物,不进入 Git,不污染全局 `~/.claude/skills`。同时仍保留 prompt catalog,方便 agent 明确知道哪些是 botmux priority skills。 + +检查某个 bot 或 CLI 的解析结果: + +```bash +botmux skills resolve --bot --cwd +botmux skills delivery --bot --cwd +botmux skills delivery --cli codex --mode auto +botmux skills delivery --cli claude-code --mode auto +``` + +## Sandbox + +开启文件 sandbox 时,prompt delivery 仍通过 `botmux skill read` 按 manifest 读取 selected skills;本功能不会额外把 `~/.botmux/skills` 作为可写目录挂给 CLI,也不会把 selected skills 写入 CLI 全局目录。注意 botmux 当前 sandbox 是 read-all / write-isolated 模型,host 文件系统的只读可见性仍遵循既有 sandbox 规则;需要隐藏具体路径时继续使用 bot 的 sandbox hidePaths 配置。Claude native delivery 需要 CLI 直接读取 runtime plugin 目录,botmux 会把这个会话级目录以只读方式挂入 sandbox。 + +## 排障 + +常用命令: + +```bash +botmux skills doctor +botmux skills resolve --bot --cwd +botmux skills delivery --bot --cwd +``` + +如果某个 bot 没有配置 custom skills,`resolve` 会显示 `skills: default`,表示新能力没有接管或改变底层 CLI 的默认 skill 加载行为。 diff --git a/src/adapters/backend/sandbox.ts b/src/adapters/backend/sandbox.ts index d21bb577..263c516a 100644 --- a/src/adapters/backend/sandbox.ts +++ b/src/adapters/backend/sandbox.ts @@ -133,6 +133,8 @@ export interface SandboxPlan { * the CLI's token refresh / login persists — unlike project edits which are * isolated). Resolved + existence-filtered by prepareSandbox. */ authReal?: string[]; + /** Runtime-generated roots that the CLI must see but must not mutate. */ + readonlyRoots?: string[]; /** Keep network egress. File-only scope ⇒ default true (npm/pip/git work). */ net?: boolean; } @@ -161,6 +163,8 @@ export function buildSandboxArgs(plan: SandboxPlan): string[] { // Per-bot privacy masks (opt-in, no defaults). for (const dir of plan.hideDirs) a.push('--tmpfs', dir); for (const f of plan.hideFiles) a.push('--ro-bind', f.empty, f.path); + // Session-scoped runtime inputs, e.g. generated skill/plugin dirs. + for (const root of plan.readonlyRoots ?? []) a.push('--ro-bind', root, root); // Outbox LAST so it wins even if a mask covers a parent dir. a.push('--bind', plan.outbox, plan.outbox); // Isolate namespaces (keep net unless explicitly disabled). @@ -288,6 +292,8 @@ export function prepareSandbox(opts: { * spawned inside the sandbox beyond cliBin — re-exposed if under /run. ONLY * executable paths (never cwd/path args). undefined → none. */ extraExecPaths?: readonly string[]; + /** Runtime-generated roots that should be visible read-only inside bwrap. */ + readonlyRoots?: readonly string[]; }): SandboxSpawn | null { if (!opts.enabled) return null; if (process.platform !== 'linux') return null; // overlayfs + bwrap are Linux-only @@ -374,6 +380,12 @@ export function prepareSandbox(opts: { try { if (existsSync(p)) authReal.push(p); } catch { /* */ } } + const readonlyRoots: string[] = []; + for (const raw of opts.readonlyRoots ?? []) { + if (!raw || typeof raw !== 'string') continue; + try { if (existsSync(raw)) readonlyRoots.push(raw); } catch { /* */ } + } + const plan: SandboxPlan = { projectMount, projectMerged: projMerged, @@ -383,6 +395,7 @@ export function prepareSandbox(opts: { hideDirs, hideFiles, authReal, + readonlyRoots, net: true, }; const args = buildSandboxArgs(plan); diff --git a/src/adapters/cli/claude-code.ts b/src/adapters/cli/claude-code.ts index 8ea60beb..67c1764b 100644 --- a/src/adapters/cli/claude-code.ts +++ b/src/adapters/cli/claude-code.ts @@ -442,6 +442,7 @@ export function createClaudeFamilyAdapter(variant: ClaudeFamilyVariant, rawBin: claudeStateJsonPath: variant.stateJsonPath, spawnEnv: variant.spawnEnv, authPaths: variant.authPaths, + skillDelivery: { nativeKind: 'claude-plugin', supportsScopedSession: true, supportsExclusive: false }, /** Prove the resume JSONL exists (or at least the project dir does, so the * sessionId lookup will find it). Conservative: only returns true when we @@ -488,7 +489,7 @@ export function createClaudeFamilyAdapter(variant: ClaudeFamilyVariant, rawBin: return discoverClaudeFamilySessions(variant.dataDir, limit, exclude); }, - buildArgs({ sessionId, resume, resumeSessionId, botName, botOpenId, locale, model, disableCliBypass }) { + buildArgs({ sessionId, resume, resumeSessionId, botName, botOpenId, locale, model, disableCliBypass, skillPluginDir }) { const args: string[] = []; if (resume) { args.push('--resume', resumeSessionId ?? sessionId); @@ -529,6 +530,7 @@ export function createClaudeFamilyAdapter(variant: ClaudeFamilyVariant, rawBin: // Keeps them out of the user's global ~/.claude/skills so a standalone // `claude` never surfaces/mis-fires `botmux send` etc. args.push('--plugin-dir', CLAUDE_PLUGIN_DIR); + if (skillPluginDir) args.push('--plugin-dir', skillPluginDir); const unknown = t('ai.identity.unknown', undefined, locale); const identityBlock = botName || botOpenId diff --git a/src/adapters/cli/types.ts b/src/adapters/cli/types.ts index 1817bbd7..5693f516 100644 --- a/src/adapters/cli/types.ts +++ b/src/adapters/cli/types.ts @@ -45,6 +45,12 @@ export interface ResumableSession { lastActivityAt: number; } +export interface SkillDeliveryCapability { + readonly nativeKind: 'claude-plugin' | 'skill-root'; + readonly supportsScopedSession: boolean; + readonly supportsExclusive: boolean; +} + export interface CliAdapter { /** Unique identifier */ readonly id: string; @@ -75,6 +81,8 @@ export interface CliAdapter { model?: string; /** When true, do not add adapter-default flags that bypass CLI approvals or disable sandboxing. */ disableCliBypass?: boolean; + /** Optional session-scoped skill plugin/root prepared by botmux. */ + skillPluginDir?: string; }): string[]; /** When true, the adapter passes the initial prompt via CLI args (e.g. -i). @@ -143,6 +151,11 @@ export interface CliAdapter { * (and mis-fire) them. Mutually exclusive with `skillsDir`. */ readonly pluginDir?: string; + /** Optional native skill delivery support for user/team custom skills. + * This is separate from `skillsDir`/`pluginDir`, which are still used by + * botmux-owned built-in bridge skills. */ + readonly skillDelivery?: SkillDeliveryCapability; + /** hook 安装描述:spawn 时写入各 CLI 的 hook 配置,使 askUserQuestion 事件转发到 * `botmux hook `。undefined = 不通过 hook 接管 askUserQuestion。 */ readonly hookInstall?: { diff --git a/src/bot-registry.ts b/src/bot-registry.ts index 68ede0a3..6f925899 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -8,6 +8,7 @@ import { logger } from './utils/logger.js'; import { isLocale, setBotLookup, type Locale } from './i18n/index.js'; import type { VoiceConfig } from './services/voice/types.js'; import { type Brand, sdkDomain, normalizeBrand } from './im/lark/lark-hosts.js'; +import type { BotSkillPolicy, SkillSelector } from './core/skills/types.js'; export type ChatReplyMode = 'chat' | 'new-topic' | 'shared'; @@ -171,6 +172,11 @@ export interface BotConfig { * 未配置(undefined)→ 仅用内置白名单(保持现状)。 */ customPassthroughCommands?: string[]; + /** + * Optional per-bot priority skill policy. Missing means botmux does not alter + * the underlying CLI's native skill discovery or spawn arguments. + */ + skills?: BotSkillPolicy; /** * Custom footer brand label for cards this bot sends. Three states: * • `undefined` (unset) → default `[botmux](github)` link @@ -711,6 +717,8 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] { if (uniq.length > 0) customPassthroughCommands = uniq; } + const skills = readBotSkillPolicy(entry.skills); + // voice:per-bot 语音引擎覆盖。结构化保留(engine ∈ sami|openai,sami/openai // 为对象,speaker/rate 透传);非对象或 engine 非法 → undefined。深度校验 // (凭证是否可用)在 resolveVoiceConfig 做,这里只挡明显垃圾。 @@ -769,6 +777,7 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] { quotaState, restrictGrantCommands: entry.restrictGrantCommands === true || undefined, customPassthroughCommands, + skills, lang: isLocale(entry.lang) ? entry.lang : undefined, // Preserve '' distinctly from undefined: '' means "brand off", undefined // means "use default botmux brand". Don't trim-to-undefined here. @@ -810,3 +819,27 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] { return configs; } + +function readStringArray(raw: unknown): string[] | undefined { + if (!Array.isArray(raw)) return undefined; + const values = raw + .map((v) => typeof v === 'string' ? v.trim() : '') + .filter(Boolean); + return values.length > 0 ? values : undefined; +} + +function readDirectSkillSelectors(raw: unknown): SkillSelector[] | undefined { + const values = readStringArray(raw); + if (!values) return undefined; + const selectors = values.filter((value): value is SkillSelector => /^skill:.+$/.test(value)); + return selectors.length > 0 ? selectors : undefined; +} + +export function readBotSkillPolicy(raw: unknown): BotSkillPolicy | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + const r = raw as Record; + const out: BotSkillPolicy = {}; + const include = readDirectSkillSelectors(r.include); + if (include) out.include = include; + return Object.keys(out).length > 0 ? out : undefined; +} diff --git a/src/cli.ts b/src/cli.ts index e9cd7c66..66c76370 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5376,6 +5376,22 @@ switch (command) { await cmdAsk(sub, rest); break; } + case 'skill': { + const { runSkillSessionCommand } = await import('./core/skills/cli-session-command.js'); + const result = runSkillSessionCommand(process.argv.slice(3)); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + process.exitCode = result.code; + break; + } + case 'skills': { + const { runSkillsAdminCommand } = await import('./core/skills/cli-admin-command.js'); + const result = runSkillsAdminCommand(process.argv.slice(3)); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + process.exitCode = result.code; + break; + } case 'hook': { // `botmux hook ` — hook 客户端,stdin 读 payload,stdout 写 directive const cliId = process.argv[3] ?? ''; diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index c909aef9..33072fe0 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -38,7 +38,7 @@ import { import { bindOncall, unbindOncall, getOncallStatus } from '../services/oncall-store.js'; import { CONFIG_FIELDS, findConfigField, settableFieldKeys, parseBooleanValue, - applyConfigField, setBotAllowedUsers, getConfigSnapshot, getConfigCardData, type ConfigEffect, + applyConfigField, setBotAllowedUsers, getConfigSnapshot, getConfigCardData, coerceConfigValue, type ConfigEffect, } from '../services/bot-config-store.js'; import { resolveCliId, findInvalidAllowedUserEntries } from '../setup/bot-config-editor.js'; import { buildClosedSessionCard } from './closed-session-card.js'; @@ -53,10 +53,11 @@ import type { LarkMessage, DaemonToWorker } from '../types.js'; import { sessionKey, sessionAnchorId } from './types.js'; import type { DaemonSession } from './types.js'; import { t, localeForBot, type Locale } from '../i18n/index.js'; +import { runSkillsImCommand } from './skills/im-command.js'; // ─── Exported constants ────────────────────────────────────────────────────── -export const DAEMON_COMMANDS = new Set(['/close', '/restart', '/status', '/help', '/cd', '/repo', '/schedule', '/role', '/botconfig', '/pair', '/login', '/adopt', '/detach', '/disconnect', '/oncall', '/group', '/g', '/relay', '/card', '/term', '/list-slash-command', '/slash', '/land', '/subscribe-lark-doc']); +export const DAEMON_COMMANDS = new Set(['/close', '/restart', '/status', '/help', '/cd', '/repo', '/schedule', '/role', '/botconfig', '/skills', '/pair', '/login', '/adopt', '/detach', '/disconnect', '/oncall', '/group', '/g', '/relay', '/card', '/term', '/list-slash-command', '/slash', '/land', '/subscribe-lark-doc']); /** * Daemon commands that act on the chat itself rather than opening a @@ -66,7 +67,7 @@ export const DAEMON_COMMANDS = new Set(['/close', '/restart', '/status', '/help' * card buttons routable, but for these that record is a phantom conversation * that pollutes the dashboard's session list. Handle them without a session. */ -export const SESSIONLESS_DAEMON_COMMANDS = new Set(['/group', '/g', '/list-slash-command', '/slash', '/botconfig']); +export const SESSIONLESS_DAEMON_COMMANDS = new Set(['/group', '/g', '/list-slash-command', '/slash', '/botconfig', '/skills']); /** * Slash commands that are forwarded verbatim to the underlying CLI (e.g. @@ -709,7 +710,7 @@ async function handleConfigCommand( const rawValue = parts.slice(2).join(' ').trim(); if (!rawValue) { await reply(t('cmd.config.value_required', { field: spec.key }, loc)); return; } - let value: string | boolean; + let value: unknown; switch (spec.kind) { case 'boolean': { const b = parseBooleanValue(rawValue); @@ -740,6 +741,12 @@ async function handleConfigCommand( value = rawValue; // 存原始(保留 ~),与 workingDir 落盘一致;使用处再 expandHome break; } + case 'json': { + const coerced = coerceConfigValue(spec, rawValue); + if (!coerced.ok) { await reply(t('cmd.config.write_failed', { reason: coerced.reason }, loc)); return; } + value = coerced.value; + break; + } default: // 'string' value = rawValue; } @@ -1426,6 +1433,26 @@ export async function handleCommand( break; } + case '/skills': { + const appId = larkAppId ?? ds?.larkAppId; + if (!appId) { + await sessionReply(rootId, t('cmd.config.no_bot', undefined, loc)); + break; + } + const sub = message.content.replace(/^\/skills\s*/i, '').trim().split(/\s+/, 1)[0]?.toLowerCase(); + if (sub === 'attach' || sub === 'detach') { + let bot; + try { bot = getBot(appId); } catch { await sessionReply(rootId, t('cmd.config.no_bot', undefined, loc)); break; } + const admins = bot.resolvedAllowedUsers ?? []; + if (admins.length === 0) { await sessionReply(rootId, t('cmd.config.no_owner', undefined, loc)); break; } + if (!message.senderId || !admins.includes(message.senderId)) { await sessionReply(rootId, t('cmd.config.not_admin', undefined, loc)); break; } + } + const result = await runSkillsImCommand(appId, message.content); + await sessionReply(rootId, result.message); + logger.info(`[${logTag}] Skills command handled: ${result.ok ? 'ok' : 'error'}`); + break; + } + case '/pair': { const code = message.content.replace(/^\/pair\s*/, '').trim(); if (!larkAppId) { await sessionReply(rootId, t('role.no_chat', undefined, loc)); break; } diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index 77fd4328..13d16131 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -57,9 +57,11 @@ import { getBotName, type SessionRow, } from './dashboard-rows.js'; -import { getBotBrand, getBot } from '../bot-registry.js'; +import { getBotBrand, getBot, readBotSkillPolicy } from '../bot-registry.js'; import { normalizeKanbanColumn, normalizeKanbanPosition, normalizeSessionTitle } from './session-board.js'; import type { ScheduledTask, ParsedSchedule, Session } from '../types.js'; +import { attachSkillPolicy, detachSkillPolicy } from './skills/im-command.js'; +import { readSkillRegistry } from '../services/skill-registry-store.js'; export interface IpcServerHandle { port: number; @@ -864,6 +866,7 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { restrictGrantCommands: grantPrefs.restrictGrantCommands, messageQuotaDefaultLimit: grantPrefs.messageQuotaDefaultLimit, p2pMode, + skills: getBot(cachedLarkAppId).config.skills ?? null, }); }); @@ -968,6 +971,48 @@ ipcRoute('PUT', '/api/bot-p2p-mode', async (req, res) => { jsonRes(res, 200, { ok: true, p2pMode: value ?? 'thread' }); }); +// Per-bot skill policy. Dashboard uses this for attach/detach; JSON policy +// still shares the same applyConfigField path as /botconfig. +ipcRoute('PUT', '/api/bot-skills', async (req, res) => { + if (!cachedLarkAppId) return jsonRes(res, 503, { error: 'larkAppId_not_set' }); + let raw: unknown; + try { raw = await readJsonBody(req); } + catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return jsonRes(res, 400, { ok: false, error: 'bad_json' }); + } + const body = raw as { action?: unknown; name?: unknown; policy?: unknown }; + const spec = findConfigField('skills'); + if (!spec) return jsonRes(res, 500, { ok: false, error: 'spec_missing' }); + + const current = getBot(cachedLarkAppId).config.skills; + let next = current; + if (body.action === 'attach') { + const name = typeof body.name === 'string' ? body.name.trim() : ''; + if (!name) return jsonRes(res, 400, { ok: false, error: 'name_required' }); + if (!readSkillRegistry().skills[name]) return jsonRes(res, 400, { ok: false, error: 'skill_not_installed' }); + next = attachSkillPolicy(current, name); + } else if (body.action === 'detach') { + const name = typeof body.name === 'string' ? body.name.trim() : ''; + if (!name) return jsonRes(res, 400, { ok: false, error: 'name_required' }); + next = detachSkillPolicy(current, name); + } else if (body.action === 'set') { + if (body.policy === null) { + next = undefined; + } else { + const parsed = readBotSkillPolicy(body.policy); + if (!parsed) return jsonRes(res, 400, { ok: false, error: 'invalid_policy' }); + next = parsed; + } + } else { + return jsonRes(res, 400, { ok: false, error: 'invalid_action' }); + } + + const r = await applyConfigField(cachedLarkAppId, spec, next ?? null); + if (!r.ok) return jsonRes(res, 400, { ok: false, error: r.reason }); + jsonRes(res, 200, { ok: true, skills: getBot(cachedLarkAppId).config.skills ?? null }); +}); + // Per-bot file-sandbox toggle. Body `{ enabled: boolean }`. When on, this bot's // CLI sessions run inside a per-session bwrap file sandbox (Linux). For oncall // bots shared with semi-trusted users. diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index ffb852ac..fc0303d1 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -30,6 +30,8 @@ import { usageLimitStateKey } from '../utils/cli-usage-limit.js'; import { t, localeForBot, type Locale } from '../i18n/index.js'; import { parseWorkingDirList } from '../utils/working-dir.js'; import { resolveRole } from './role-resolver.js'; +import { renderSkillCatalogBlock } from './skills/prompt.js'; +import type { SessionSkillManifest } from './skills/types.js'; function sessionCreatedAtMs(session: { createdAt?: string }): number { return session.createdAt ? (Date.parse(session.createdAt) || Date.now()) : Date.now(); @@ -257,7 +259,7 @@ export function buildNewTopicPrompt( botIdentity?: { name?: string; openId?: string }, locale?: Locale, sender?: ResolvedSender, - opts?: { larkAppId?: string; chatId?: string }, + opts?: { larkAppId?: string; chatId?: string; skillManifest?: SessionSkillManifest }, ): string { const adapter = createCliAdapterSync(cliId, cliPathOverride); // Non-Claude CLIs receive the botmux routing hints inline via the prompt @@ -342,6 +344,8 @@ export function buildNewTopicPrompt( // and session ID via system prompt, so skip those blocks here. if (mentionBlock) parts.push(mentionBlock); if (botBlock) parts.push(botBlock); + const skillBlock = renderSkillCatalogBlock(opts?.skillManifest); + if (skillBlock) parts.push(skillBlock); return parts.join('\n\n'); } diff --git a/src/core/skills/claude-plugin-delivery.ts b/src/core/skills/claude-plugin-delivery.ts new file mode 100644 index 00000000..cbb6f5df --- /dev/null +++ b/src/core/skills/claude-plugin-delivery.ts @@ -0,0 +1,26 @@ +import { cpSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { config } from '../../config.js'; +import { atomicWriteFileSync } from '../../utils/atomic-write.js'; +import type { SessionSkillManifest } from './types.js'; + +export interface ClaudeSkillPluginPrepared { + pluginDir: string; +} + +export function prepareClaudeSkillPlugin(manifest: SessionSkillManifest): ClaudeSkillPluginPrepared { + const pluginDir = join(config.session.dataDir, 'runtime-skills', manifest.sessionId, 'claude-plugin'); + rmSync(pluginDir, { recursive: true, force: true }); + mkdirSync(join(pluginDir, '.claude-plugin'), { recursive: true }); + mkdirSync(join(pluginDir, 'skills'), { recursive: true }); + atomicWriteFileSync(join(pluginDir, '.claude-plugin', 'plugin.json'), JSON.stringify({ + name: 'botmux-session-skills', + description: 'botmux per-session priority skills', + version: '1.0.0', + author: { name: 'botmux' }, + }, null, 2) + '\n'); + for (const skill of manifest.prioritySkills) { + cpSync(skill.rootDir, join(pluginDir, 'skills', skill.name), { recursive: true }); + } + return { pluginDir }; +} diff --git a/src/core/skills/cli-admin-command.ts b/src/core/skills/cli-admin-command.ts new file mode 100644 index 00000000..7b2cef1a --- /dev/null +++ b/src/core/skills/cli-admin-command.ts @@ -0,0 +1,246 @@ +import { existsSync } from 'node:fs'; +import { githubToGitUrl, parseSkillInstallSource } from './sources.js'; +import { validateSkillPackageDir } from './package.js'; +import { + installGitSkill, + installLocalSkill, + readSkillRegistry, + removeInstalledSkill, + updateInstalledSkill, +} from '../../services/skill-registry-store.js'; +import type { BotConfig } from '../../bot-registry.js'; +import { loadBotConfigs } from '../../bot-registry.js'; +import { readGlobalConfig } from '../../global-config.js'; +import { createCliAdapterSync } from '../../adapters/cli/registry.js'; +import type { CliId } from '../../adapters/cli/types.js'; +import { discoverProjectSkills } from './discovery.js'; +import { resolveSkillPolicy } from './policy.js'; +import { analyzeSkillReferences, type SkillReferenceSummary } from './references.js'; +import type { SkillPackage, SkillSource } from './types.js'; + +export interface AdminCommandResult { + code: number; + stdout: string; + stderr: string; +} + +function argValue(args: string[], name: string): string | undefined { + const i = args.indexOf(name); + return i >= 0 ? args[i + 1] : undefined; +} + +function hasFlag(args: string[], name: string): boolean { + return args.includes(name); +} + +function findBotConfig(selector: string | undefined): BotConfig | undefined { + if (!selector) return undefined; + const bots = loadBotConfigs(); + const asNumber = Number(selector); + if (Number.isInteger(asNumber) && asNumber > 0) return bots[asNumber - 1]; + return bots.find((bot) => bot.larkAppId === selector || bot.name === selector); +} + +function skillLine(skill: SkillPackage & { priorityReason?: string }): string { + return [ + skill.name, + skill.priorityReason ?? '', + skill.description ?? '', + ].filter((part) => part.length > 0).join('\t'); +} + +function runDoctor(): AdminCommandResult { + const registry = readSkillRegistry(); + const skills = Object.values(registry.skills).sort((a, b) => a.name.localeCompare(b.name)); + const lines = [ + `installed: ${skills.length}`, + ]; + let broken = 0; + for (const skill of skills) { + const exists = existsSync(skill.rootDir); + const valid = exists ? validateSkillPackageDir(skill.rootDir) : { ok: false as const, reason: 'missing_root' }; + if (valid.ok) { + lines.push(`ok\t${skill.name}\t${skill.rootDir}`); + } else { + broken++; + lines.push(`broken\t${skill.name}\t${valid.reason}\t${skill.rootDir}`); + } + } + return { code: broken > 0 ? 1 : 0, stdout: lines.join('\n') + '\n', stderr: '' }; +} + +function runResolve(args: string[]): AdminCommandResult { + const botSelector = argValue(args, '--bot'); + const cwd = argValue(args, '--cwd') ?? process.cwd(); + if (!botSelector) return { code: 2, stdout: '', stderr: 'usage: botmux skills resolve --bot [--cwd ]\n' }; + const bot = findBotConfig(botSelector); + if (!bot) return { code: 2, stdout: '', stderr: `bot not found: ${botSelector}\n` }; + if (!bot.skills) { + return { + code: 0, + stdout: [ + `bot: ${bot.name ?? bot.larkAppId}`, + `cli: ${bot.cliId}`, + 'skills: default', + 'note: bot has no custom skill policy; CLI-native skills remain unchanged', + ].join('\n') + '\n', + stderr: '', + }; + } + + const globalSkills = readGlobalConfig().skills; + const result = resolveSkillPolicy({ + registrySkills: Object.values(readSkillRegistry().skills), + projectSkills: discoverProjectSkills(cwd), + globalProjectSkills: globalSkills?.trustProjectSkills, + globalDelivery: globalSkills?.delivery, + botPolicy: bot.skills, + workingDir: cwd, + }); + const lines = [ + `bot: ${bot.name ?? bot.larkAppId}`, + `cli: ${bot.cliId}`, + `cwd: ${cwd}`, + `mode: ${result.mode}`, + `delivery: ${result.delivery}`, + `skills: ${result.prioritySkills.length}`, + ...result.prioritySkills.map(skillLine), + ]; + if (result.diagnostics.length > 0) { + lines.push('diagnostics:'); + for (const diagnostic of result.diagnostics) { + lines.push(`${diagnostic.level}\t${diagnostic.code}\t${diagnostic.message}`); + } + } + return { code: 0, stdout: lines.join('\n') + '\n', stderr: '' }; +} + +function deliverySummary(cliId: CliId, requested: 'auto' | 'prompt' | 'native'): string { + const adapter = createCliAdapterSync(cliId); + const native = adapter.skillDelivery?.nativeKind ?? 'none'; + if (requested === 'prompt') return `cli: ${cliId}\nrequested: prompt\nnative: ${native}\ndelivery: prompt\n`; + if (requested === 'native') { + const delivery = native === 'none' ? 'unsupported' : 'hybrid'; + return `cli: ${cliId}\nrequested: native\nnative: ${native}\ndelivery: ${delivery}\n`; + } + const delivery = native === 'none' ? 'prompt' : 'hybrid'; + return `cli: ${cliId}\nrequested: auto\nnative: ${native}\ndelivery: ${delivery}\n`; +} + +function runDelivery(args: string[]): AdminCommandResult { + const botSelector = argValue(args, '--bot'); + let cliId = argValue(args, '--cli') as CliId | undefined; + let requested = argValue(args, '--mode') as 'auto' | 'prompt' | 'native' | undefined; + if (requested && requested !== 'auto' && requested !== 'prompt' && requested !== 'native') { + return { code: 2, stdout: '', stderr: 'usage: botmux skills delivery [--bot ] [--cli ] [--mode auto|prompt|native]\n' }; + } + if (botSelector) { + const bot = findBotConfig(botSelector); + if (!bot) return { code: 2, stdout: '', stderr: `bot not found: ${botSelector}\n` }; + cliId ??= bot.cliId; + requested ??= readGlobalConfig().skills?.delivery ?? 'auto'; + } + if (!cliId) return { code: 2, stdout: '', stderr: 'usage: botmux skills delivery [--bot ] [--cli ] [--mode auto|prompt|native]\n' }; + try { + return { code: 0, stdout: deliverySummary(cliId, requested ?? 'auto'), stderr: '' }; + } catch (err: any) { + return { code: 2, stdout: '', stderr: `${err?.message ?? err}\n` }; + } +} + +function findSkillReferences(skillName: string): SkillReferenceSummary { + let bots: BotConfig[] = []; + try { + bots = loadBotConfigs(); + } catch { + // CLI commands can run before bots.json exists; skip bot refs in that case. + } + return analyzeSkillReferences(skillName, { bots }); +} + +function formatSkillReferenceWarning(refs: SkillReferenceSummary): string { + const lines = ['skill_in_use']; + if (refs.bots.length > 0) lines.push(`bots: ${refs.bots.map((bot) => bot.botName).join(', ')}`); + lines.push('use --force to remove anyway'); + return lines.join('\n') + '\n'; +} + +export function runSkillsAdminCommand(args: string[]): AdminCommandResult { + const sub = args[0] ?? 'list'; + try { + if (sub === 'list') { + const skills = Object.values(readSkillRegistry().skills).sort((a, b) => a.name.localeCompare(b.name)); + const lines = skills.map((skill) => `${skill.name}\t${skill.description ?? ''}`.trimEnd()); + return { code: 0, stdout: lines.join('\n') + (lines.length > 0 ? '\n' : ''), stderr: '' }; + } + if (sub === 'inspect') { + const name = args[1]; + const skill = name ? readSkillRegistry().skills[name] : undefined; + if (!skill) return { code: 2, stdout: '', stderr: 'skill not found\n' }; + return { code: 0, stdout: JSON.stringify(skill, null, 2) + '\n', stderr: '' }; + } + if (sub === 'validate') { + const dir = args[1]; + if (!dir) return { code: 2, stdout: '', stderr: 'usage: botmux skills validate \n' }; + const result = validateSkillPackageDir(dir); + return result.ok ? { code: 0, stdout: 'ok\n', stderr: '' } : { code: 1, stdout: '', stderr: `${result.reason}\n` }; + } + if (sub === 'install') { + const source = args[1]; + if (!source) return { code: 2, stdout: '', stderr: 'usage: botmux skills install \n' }; + const parsed = parseSkillInstallSource(source); + if (parsed.kind === 'local') { + const pkg = installLocalSkill(parsed.value, { link: hasFlag(args, '--link') }); + return { code: 0, stdout: `installed ${pkg.name}\n`, stderr: '' }; + } + if (parsed.kind === 'git') { + const path = argValue(args, '--path'); + if (!path) return { code: 2, stdout: '', stderr: 'git install requires --path \n' }; + const pkg = installGitSkill({ url: parsed.value, path, ref: argValue(args, '--ref') }); + return { code: 0, stdout: `installed ${pkg.name}\n`, stderr: '' }; + } + const gh = parsed.github; + if (!gh) return { code: 2, stdout: '', stderr: 'invalid github source\n' }; + const path = argValue(args, '--path') ?? gh.path; + if (!path) return { code: 2, stdout: '', stderr: 'github install requires a repo path or --path \n' }; + const ref = argValue(args, '--ref'); + const sourceOverride: SkillSource = { type: 'github', owner: gh.owner, repo: gh.repo, path, ...(ref ? { ref } : {}) }; + const pkg = installGitSkill({ + url: githubToGitUrl(gh.owner, gh.repo), + path, + ref, + sourceOverride, + }); + return { code: 0, stdout: `installed ${pkg.name}\n`, stderr: '' }; + } + if (sub === 'remove') { + const name = args[1]; + if (!name) return { code: 2, stdout: '', stderr: 'usage: botmux skills remove [--force]\n' }; + if (!readSkillRegistry().skills[name]) return { code: 1, stdout: '', stderr: 'skill_not_installed\n' }; + const refs = findSkillReferences(name); + if (!hasFlag(args, '--force') && refs.bots.length > 0) { + return { code: 1, stdout: '', stderr: formatSkillReferenceWarning(refs) }; + } + const result = removeInstalledSkill(name); + return result.ok ? { code: 0, stdout: `removed ${name}\n`, stderr: '' } : { code: 1, stdout: '', stderr: `${result.reason}\n` }; + } + if (sub === 'update') { + const name = args[1]; + if (!name) return { code: 2, stdout: '', stderr: 'usage: botmux skills update \n' }; + const result = updateInstalledSkill(name); + return result.ok ? { code: 0, stdout: `updated ${result.skill.name}\n`, stderr: '' } : { code: 1, stdout: '', stderr: `${result.reason}\n` }; + } + if (sub === 'doctor') { + return runDoctor(); + } + if (sub === 'resolve') { + return runResolve(args.slice(1)); + } + if (sub === 'delivery') { + return runDelivery(args.slice(1)); + } + return { code: 2, stdout: '', stderr: `unknown skills command: ${sub}\n` }; + } catch (err: any) { + return { code: 1, stdout: '', stderr: `${err?.message ?? err}\n` }; + } +} diff --git a/src/core/skills/cli-session-command.ts b/src/core/skills/cli-session-command.ts new file mode 100644 index 00000000..630c75bb --- /dev/null +++ b/src/core/skills/cli-session-command.ts @@ -0,0 +1,49 @@ +import { readSessionSkillManifest } from './manifest-store.js'; +import { listSkillResources, readSkillEntrypoint, readSkillResource } from './resource-reader.js'; + +export interface SkillCommandResult { + code: number; + stdout: string; + stderr: string; +} + +function sessionIdFromEnv(env: Record): string | undefined { + return env.BOTMUX_SESSION_ID; +} + +export function runSkillSessionCommand( + args: string[], + env: Record = process.env, +): SkillCommandResult { + const sessionId = sessionIdFromEnv(env); + if (!sessionId) return { code: 2, stdout: '', stderr: 'missing BOTMUX_SESSION_ID\n' }; + const manifest = readSessionSkillManifest(sessionId); + if (!manifest) return { code: 2, stdout: '', stderr: `skill manifest not found for session ${sessionId}\n` }; + const sub = args[0] ?? 'list'; + try { + if (sub === 'list') { + const lines = manifest.prioritySkills.map((skill) => `${skill.name}\t${skill.description ?? ''}`.trimEnd()); + return { code: 0, stdout: lines.join('\n') + (lines.length > 0 ? '\n' : ''), stderr: '' }; + } + if (sub === 'show') { + const name = args[1]; + if (!name) return { code: 2, stdout: '', stderr: 'usage: botmux skill show \n' }; + return { code: 0, stdout: readSkillEntrypoint(manifest, name).content, stderr: '' }; + } + if (sub === 'read') { + const name = args[1]; + const path = args[2]; + if (!name || !path) return { code: 2, stdout: '', stderr: 'usage: botmux skill read \n' }; + return { code: 0, stdout: readSkillResource(manifest, name, path).content, stderr: '' }; + } + if (sub === 'resources') { + const name = args[1]; + const skill = manifest.prioritySkills.find((candidate) => candidate.name === name); + if (!name || !skill) return { code: 2, stdout: '', stderr: 'usage: botmux skill resources \n' }; + return { code: 0, stdout: listSkillResources(manifest, name).join('\n') + '\n', stderr: '' }; + } + return { code: 2, stdout: '', stderr: `unknown skill command: ${sub}\n` }; + } catch (err: any) { + return { code: 1, stdout: '', stderr: `${err?.message ?? err}\n` }; + } +} diff --git a/src/core/skills/delivery.ts b/src/core/skills/delivery.ts new file mode 100644 index 00000000..6ef6f2a2 --- /dev/null +++ b/src/core/skills/delivery.ts @@ -0,0 +1,35 @@ +import type { CliAdapter } from '../../adapters/cli/types.js'; +import type { SessionSkillManifest } from './types.js'; +import { prepareClaudeSkillPlugin } from './claude-plugin-delivery.js'; + +export interface PreparedSkillDelivery { + prompt: boolean; + pluginDir?: string; + readonlyRoots: string[]; + diagnostics: string[]; + fatal?: boolean; +} + +export function prepareSkillDelivery( + adapter: CliAdapter, + manifest: SessionSkillManifest | null, + requested: 'auto' | 'prompt' | 'native', +): PreparedSkillDelivery { + if (!manifest || manifest.prioritySkills.length === 0) { + return { prompt: false, readonlyRoots: [], diagnostics: [] }; + } + if (requested === 'prompt') return { prompt: true, readonlyRoots: [], diagnostics: [] }; + if (adapter.skillDelivery?.nativeKind === 'claude-plugin') { + const prepared = prepareClaudeSkillPlugin(manifest); + return { prompt: true, pluginDir: prepared.pluginDir, readonlyRoots: [prepared.pluginDir], diagnostics: [] }; + } + if (requested === 'native') { + return { + prompt: false, + readonlyRoots: [], + diagnostics: ['native_skill_delivery_not_supported'], + fatal: true, + }; + } + return { prompt: true, readonlyRoots: [], diagnostics: [] }; +} diff --git a/src/core/skills/discovery.ts b/src/core/skills/discovery.ts new file mode 100644 index 00000000..837cf18e --- /dev/null +++ b/src/core/skills/discovery.ts @@ -0,0 +1,33 @@ +import { existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { loadSkillPackage } from './package.js'; +import type { SkillPackage } from './types.js'; + +function listSkillDirs(root: string): string[] { + if (!existsSync(root)) return []; + try { + return readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(root, entry.name)); + } catch { + return []; + } +} + +export function discoverProjectSkills(workingDir: string): SkillPackage[] { + const roots = [ + join(workingDir, '.agents', 'skills'), + join(workingDir, '.botmux', 'skills'), + ]; + const out: SkillPackage[] = []; + for (const root of roots) { + for (const dir of listSkillDirs(root)) { + try { + out.push(loadSkillPackage(dir, { source: { type: 'project', root: dir } })); + } catch { + // Bad project-local skills should surface through diagnostics later, not break spawn. + } + } + } + return out; +} diff --git a/src/core/skills/frontmatter.ts b/src/core/skills/frontmatter.ts new file mode 100644 index 00000000..37e2ca70 --- /dev/null +++ b/src/core/skills/frontmatter.ts @@ -0,0 +1,45 @@ +export interface SkillFrontmatter { + name?: string; + description?: string; + version?: string; + displayName?: string; + tags?: string[]; +} + +function cleanScalar(raw: string): string { + const v = raw.trim(); + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { + return v.slice(1, -1); + } + return v; +} + +function parseTags(raw: string): string[] { + const v = raw.trim(); + if (!v) return []; + if (!v.startsWith('[') || !v.endsWith(']')) return [cleanScalar(v)].filter(Boolean); + return v + .slice(1, -1) + .split(',') + .map((item) => cleanScalar(item)) + .filter(Boolean); +} + +export function readSkillFrontmatter(text: string): SkillFrontmatter { + if (!text.startsWith('---')) return {}; + const end = text.indexOf('\n---', 3); + if (end === -1) return {}; + const block = text.slice(3, end); + const out: SkillFrontmatter = {}; + for (const line of block.split(/\r?\n/)) { + const m = /^\s*(name|description|version|displayName|tags)\s*:\s*(.+?)\s*$/.exec(line); + if (!m) continue; + const key = m[1]; + if (key === 'tags') { + out.tags = parseTags(m[2]); + } else if (key === 'name' || key === 'description' || key === 'version' || key === 'displayName') { + out[key] = cleanScalar(m[2]); + } + } + return out; +} diff --git a/src/core/skills/im-command.ts b/src/core/skills/im-command.ts new file mode 100644 index 00000000..63fbc767 --- /dev/null +++ b/src/core/skills/im-command.ts @@ -0,0 +1,114 @@ +import type { BotSkillPolicy, SkillPackage, SkillSelector } from './types.js'; +import { getBot } from '../../bot-registry.js'; +import { applyConfigField, findConfigField } from '../../services/bot-config-store.js'; +import { readSkillRegistry } from '../../services/skill-registry-store.js'; +import { readGlobalConfig } from '../../global-config.js'; + +export interface SkillsImCommandResult { + ok: boolean; + message: string; +} + +function skillSelector(name: string): SkillSelector { + return `skill:${name}`; +} + +function policyIsEmpty(policy: BotSkillPolicy): boolean { + return !policy.include?.length; +} + +export function attachSkillPolicy(current: BotSkillPolicy | undefined, name: string): BotSkillPolicy { + const selector = skillSelector(name); + const include = new Set((current?.include ?? []).filter((item) => item.startsWith('skill:'))); + include.add(selector); + return { include: [...include] }; +} + +export function detachSkillPolicy(current: BotSkillPolicy | undefined, name: string): BotSkillPolicy | undefined { + if (!current) return undefined; + const selector = skillSelector(name); + const include = (current.include ?? []) + .filter((item) => item.startsWith('skill:')) + .filter((item) => item !== selector); + const next: BotSkillPolicy = {}; + if (include.length > 0) next.include = include; + return policyIsEmpty(next) ? undefined : next; +} + +function describeSource(skill: SkillPackage): string { + if (skill.source.type === 'github') return `github:${skill.source.owner}/${skill.source.repo}/${skill.source.path}`; + if (skill.source.type === 'git') return `${skill.source.url}#${skill.source.path}`; + return skill.source.type; +} + +function renderStatus(larkAppId: string): string { + const bot = getBot(larkAppId); + const registry = readSkillRegistry(); + const installed = Object.values(registry.skills).sort((a, b) => a.name.localeCompare(b.name)); + const policy = bot.config.skills; + const include = (policy?.include ?? []).filter((item) => item.startsWith('skill:')); + const lines = [ + `Skill policy: ${policy ? 'custom priority' : 'default CLI behavior'}`, + `CLI: ${bot.config.cliId}`, + `delivery: ${readGlobalConfig().skills?.delivery ?? 'auto'} (global)`, + `priority skills: ${include.length ? include.map((item) => item.slice('skill:'.length)).join(', ') : 'none'}`, + `installed skills: ${installed.length}`, + ]; + if (installed.length > 0) { + lines.push(...installed.slice(0, 12).map((skill) => `- ${skill.name}${skill.description ? ` — ${skill.description}` : ''} (${describeSource(skill)})`)); + if (installed.length > 12) lines.push(`... ${installed.length - 12} more`); + } + lines.push('Commands: /skills attach , /skills detach '); + return lines.join('\n'); +} + +async function writeSkillPolicy(larkAppId: string, policy: BotSkillPolicy | undefined): Promise { + const spec = findConfigField('skills'); + if (!spec) return { ok: false, message: 'skills config field is unavailable' }; + const result = await applyConfigField(larkAppId, spec, policy ?? null); + if (!result.ok) return { ok: false, message: `写入失败:${result.reason}` }; + return { ok: true, message: 'OK' }; +} + +export async function runSkillsImCommand(larkAppId: string, content: string): Promise { + const args = content.replace(/^\/skills\s*/i, '').trim(); + if (!args || args === 'bot' || args === 'status') { + return { ok: true, message: renderStatus(larkAppId) }; + } + + const [sub, rawName] = args.split(/\s+/, 2); + const name = rawName?.trim(); + if ((sub === 'attach' || sub === 'detach') && !name) { + return { ok: false, message: '用法:/skills attach 或 /skills detach ' }; + } + + if (sub === 'attach') { + const skill = readSkillRegistry().skills[name!]; + if (!skill) return { ok: false, message: `未安装 skill:${name}\n先在部署机器上运行:botmux skills install ` }; + const bot = getBot(larkAppId); + const result = await writeSkillPolicy(larkAppId, attachSkillPolicy(bot.config.skills, skill.name)); + if (!result.ok) return result; + return { ok: true, message: `已把 ${skill.name} 加入本 bot 的 priority skills。新会话生效,底层 CLI 原生 skills 仍保持可见。` }; + } + + if (sub === 'detach') { + const bot = getBot(larkAppId); + const next = detachSkillPolicy(bot.config.skills, name!); + const result = await writeSkillPolicy(larkAppId, next); + if (!result.ok) return result; + return { ok: true, message: next + ? `已从本 bot 的 priority skills 移除 ${name}。新会话生效。` + : `已清除本 bot 的 custom skill policy;新会话回到底层 CLI 默认行为。` }; + } + + return { + ok: false, + message: [ + '用法:', + '/skills', + '/skills attach ', + '/skills detach ', + '安装和诊断请在部署机器上使用:botmux skills list / doctor / resolve', + ].join('\n'), + }; +} diff --git a/src/core/skills/manifest-store.ts b/src/core/skills/manifest-store.ts new file mode 100644 index 00000000..d2b4083a --- /dev/null +++ b/src/core/skills/manifest-store.ts @@ -0,0 +1,28 @@ +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { config } from '../../config.js'; +import { atomicWriteFileSync } from '../../utils/atomic-write.js'; +import type { SessionSkillManifest } from './types.js'; + +function manifestDir(): string { + return join(config.session.dataDir, 'skill-manifests'); +} + +function manifestPath(sessionId: string): string { + return join(manifestDir(), `${sessionId}.json`); +} + +export function writeSessionSkillManifest(manifest: SessionSkillManifest): void { + mkdirSync(manifestDir(), { recursive: true }); + atomicWriteFileSync(manifestPath(manifest.sessionId), JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 }); +} + +export function readSessionSkillManifest(sessionId: string): SessionSkillManifest | null { + const file = manifestPath(sessionId); + if (!existsSync(file)) return null; + try { + return JSON.parse(readFileSync(file, 'utf-8')) as SessionSkillManifest; + } catch { + return null; + } +} diff --git a/src/core/skills/package.ts b/src/core/skills/package.ts new file mode 100644 index 00000000..26ace3cb --- /dev/null +++ b/src/core/skills/package.ts @@ -0,0 +1,39 @@ +import { existsSync, readFileSync, realpathSync } from 'node:fs'; +import { basename, join } from 'node:path'; +import { readSkillFrontmatter } from './frontmatter.js'; +import type { SkillPackage, SkillSource } from './types.js'; + +const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9:_-]{0,127}$/; + +export function isValidSkillName(name: string): boolean { + return NAME_RE.test(name); +} + +export function validateSkillPackageDir(dir: string): { ok: true } | { ok: false; reason: string } { + if (!existsSync(join(dir, 'SKILL.md'))) return { ok: false, reason: 'missing_skill_md' }; + return { ok: true }; +} + +export function loadSkillPackage( + dir: string, + opts: { source: SkillSource; id?: string }, +): SkillPackage { + const valid = validateSkillPackageDir(dir); + if (!valid.ok) throw new Error(valid.reason); + const rootDir = realpathSync(dir); + const text = readFileSync(join(rootDir, 'SKILL.md'), 'utf-8'); + const fm = readSkillFrontmatter(text); + const name = fm.name?.trim() || basename(rootDir); + if (!isValidSkillName(name)) throw new Error(`invalid_skill_name:${name}`); + return { + id: opts.id ?? name, + name, + displayName: fm.displayName, + description: fm.description, + version: fm.version, + tags: fm.tags ?? [], + rootDir, + entrypoint: 'SKILL.md', + source: opts.source, + }; +} diff --git a/src/core/skills/policy.ts b/src/core/skills/policy.ts new file mode 100644 index 00000000..41612e84 --- /dev/null +++ b/src/core/skills/policy.ts @@ -0,0 +1,96 @@ +import type { + BotSkillPolicy, + ResolvedSkill, + SkillDiagnostic, + SkillPackage, + SkillSelector, +} from './types.js'; + +export interface SkillPolicyInput { + registrySkills: SkillPackage[]; + projectSkills: SkillPackage[]; + globalProjectSkills?: 'off' | 'trusted' | 'all'; + globalDelivery?: 'auto' | 'prompt' | 'native'; + botPolicy: BotSkillPolicy | undefined; + workingDir: string; +} + +export interface SkillPolicyResult { + enabled: boolean; + mode: 'priority'; + delivery: 'auto' | 'prompt' | 'native'; + prioritySkills: ResolvedSkill[]; + diagnostics: SkillDiagnostic[]; +} + +function matchesSelector(skill: SkillPackage, selector: SkillSelector): boolean { + const [kind, ...rest] = selector.split(':'); + const value = rest.join(':'); + if (kind === 'skill') return skill.name === value; + return false; +} + +function appendMatches(out: ResolvedSkill[], skills: SkillPackage[], selector: SkillSelector, reason: string): void { + for (const skill of skills) { + if (matchesSelector(skill, selector)) out.push({ ...skill, priorityReason: reason }); + } +} + +export function resolveSkillPolicy(input: SkillPolicyInput): SkillPolicyResult { + const policy = input.botPolicy; + if (!policy) { + return { enabled: false, mode: 'priority', delivery: 'auto', prioritySkills: [], diagnostics: [] }; + } + + const diagnostics: SkillDiagnostic[] = []; + const candidates = [...input.registrySkills]; + const projectMode = input.globalProjectSkills ?? 'off'; + if (projectMode === 'trusted') { + diagnostics.push({ + level: 'warn', + code: 'project_skills_trusted_deprecated', + message: 'projectSkills:"trusted" is kept as a compatibility alias for "all"; no separate trust boundary is enforced', + }); + candidates.push(...input.projectSkills); + } else if (projectMode === 'all') { + candidates.push(...input.projectSkills); + } + + const raw: ResolvedSkill[] = []; + for (const selector of policy.include ?? []) { + if (!selector.startsWith('skill:')) continue; + appendMatches(raw, candidates, selector, 'bot:include'); + } + + const seen = new Set(); + const prioritySkills: ResolvedSkill[] = []; + for (const skill of raw) { + if (seen.has(skill.name)) { + diagnostics.push({ + level: 'warn', + code: 'duplicate_skill_shadowed', + message: `Duplicate skill shadowed: ${skill.name}`, + skillName: skill.name, + }); + continue; + } + seen.add(skill.name); + prioritySkills.push(skill); + } + + if (prioritySkills.length === 0) { + diagnostics.push({ + level: 'warn', + code: 'empty_priority_skill_set', + message: 'No skills matched bot skill policy', + }); + } + + return { + enabled: true, + mode: 'priority', + delivery: input.globalDelivery ?? 'auto', + prioritySkills, + diagnostics, + }; +} diff --git a/src/core/skills/prompt.ts b/src/core/skills/prompt.ts new file mode 100644 index 00000000..cc5ac4c7 --- /dev/null +++ b/src/core/skills/prompt.ts @@ -0,0 +1,26 @@ +import type { SessionSkillManifest } from './types.js'; + +function xmlEscape(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +export function renderSkillCatalogBlock(manifest: SessionSkillManifest | null | undefined): string { + if (!manifest || manifest.prioritySkills.length === 0) return ''; + const skills = manifest.prioritySkills.map((skill) => { + const tags = skill.tags.length > 0 ? ` tags="${xmlEscape(skill.tags.join(','))}"` : ''; + const description = skill.description + ? `\n ${xmlEscape(skill.description)}` + : ''; + return ` ${description}\n botmux skill show ${xmlEscape(skill.name)}\n `; + }); + return [ + ``, + ' Prefer these botmux priority skills when they match the task. Read an entrypoint with `botmux skill show ` and referenced files with `botmux skill read `.', + ...skills, + '', + ].join('\n'); +} diff --git a/src/core/skills/references.ts b/src/core/skills/references.ts new file mode 100644 index 00000000..82850920 --- /dev/null +++ b/src/core/skills/references.ts @@ -0,0 +1,55 @@ +import type { BotSkillPolicy, SkillSelector } from './types.js'; + +export interface SkillReferenceBotInput { + larkAppId: string; + name?: string; + botName?: string; + skills?: BotSkillPolicy | null; +} + +export interface SkillReferenceBot { + larkAppId: string; + botName: string; + direct: boolean; +} + +export interface SkillReferenceSummary { + bots: SkillReferenceBot[]; +} + +function directSkillSelector(skillName: string): SkillSelector { + return `skill:${skillName}` as SkillSelector; +} + +export function directSkillNames(policy: BotSkillPolicy | null | undefined): string[] { + return (policy?.include ?? []) + .filter((item) => item.startsWith('skill:')) + .map((item) => item.slice('skill:'.length)); +} + +export function policyIncludesDirectSkill( + policy: BotSkillPolicy | null | undefined, + skillName: string, +): boolean { + return Array.isArray(policy?.include) && policy.include.includes(directSkillSelector(skillName)); +} + +export function analyzeSkillReferences( + skillName: string, + opts: { + bots: SkillReferenceBotInput[]; + }, +): SkillReferenceSummary { + const bots: SkillReferenceBot[] = []; + for (const bot of opts.bots) { + const direct = policyIncludesDirectSkill(bot.skills, skillName); + if (!direct) continue; + bots.push({ + larkAppId: bot.larkAppId, + botName: bot.botName ?? bot.name ?? bot.larkAppId, + direct, + }); + } + bots.sort((a, b) => a.botName.localeCompare(b.botName)); + return { bots }; +} diff --git a/src/core/skills/registry-paths.ts b/src/core/skills/registry-paths.ts new file mode 100644 index 00000000..d35bf285 --- /dev/null +++ b/src/core/skills/registry-paths.ts @@ -0,0 +1,18 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export function botmuxSkillsHome(): string { + return join(homedir(), '.botmux', 'skills'); +} + +export function skillRegistryPath(): string { + return join(botmuxSkillsHome(), 'registry.json'); +} + +export function skillStoreDir(): string { + return join(botmuxSkillsHome(), 'store'); +} + +export function skillSourcesDir(): string { + return join(botmuxSkillsHome(), 'sources'); +} diff --git a/src/core/skills/resource-reader.ts b/src/core/skills/resource-reader.ts new file mode 100644 index 00000000..0e5b4218 --- /dev/null +++ b/src/core/skills/resource-reader.ts @@ -0,0 +1,95 @@ +import { lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import type { SessionSkillManifest } from './types.js'; + +const MAX_RESOURCE_BYTES = 256 * 1024; + +export interface SkillResourceReadResult { + path: string; + content: string; +} + +function assertRelativePath(path: string): void { + if (path.startsWith('/') || path.includes('\0')) throw new Error('invalid_skill_resource_path'); + if (path.split(/[\\/]/).includes('..')) throw new Error('path_outside_skill_root'); +} + +export function readSkillResource( + manifest: SessionSkillManifest, + skillName: string, + relativePath: string, +): SkillResourceReadResult { + assertRelativePath(relativePath); + const skill = manifest.prioritySkills.find((s) => s.name === skillName); + if (!skill) throw new Error('skill_not_in_session_manifest'); + const root = realpathSync(skill.rootDir); + let target: string; + try { + target = realpathSync(resolve(join(root, relativePath))); + } catch { + throw new Error('skill_resource_not_found'); + } + if (!(target === root || target.startsWith(root + '/'))) throw new Error('path_outside_skill_root'); + const stat = lstatSync(target); + if (!stat.isFile()) throw new Error('skill_resource_not_file'); + if (stat.size > MAX_RESOURCE_BYTES) throw new Error('skill_resource_too_large'); + return { path: relativePath, content: readFileSync(target, 'utf-8') }; +} + +export function readSkillEntrypoint(manifest: SessionSkillManifest, skillName: string): SkillResourceReadResult { + const skill = manifest.prioritySkills.find((s) => s.name === skillName); + if (!skill) throw new Error('skill_not_in_session_manifest'); + return readSkillResource(manifest, skillName, skill.entrypoint); +} + +export function listSkillResources(manifest: SessionSkillManifest, skillName: string): string[] { + const skill = manifest.prioritySkills.find((s) => s.name === skillName); + if (!skill) throw new Error('skill_not_in_session_manifest'); + const root = realpathSync(skill.rootDir); + const out: string[] = []; + + function withinRoot(path: string): boolean { + return path === root || path.startsWith(root + '/'); + } + + function walk(dir: string, depth: number): void { + if (depth > 4 || out.length >= 200) return; + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const name of entries) { + if (out.length >= 200) return; + const path = join(dir, name); + let stat; + try { + stat = lstatSync(path); + } catch { + continue; + } + if (stat.isSymbolicLink()) continue; + if (stat.isDirectory()) { + let realDir: string; + try { + realDir = realpathSync(path); + } catch { + continue; + } + if (withinRoot(realDir)) walk(path, depth + 1); + } else if (stat.isFile()) { + let realFile: string; + try { + realFile = realpathSync(path); + } catch { + continue; + } + if (withinRoot(realFile)) out.push(relative(root, path)); + } + } + } + + walk(root, 0); + return out.sort(); +} diff --git a/src/core/skills/session-resolver.ts b/src/core/skills/session-resolver.ts new file mode 100644 index 00000000..5a3a6f71 --- /dev/null +++ b/src/core/skills/session-resolver.ts @@ -0,0 +1,35 @@ +import type { CliId } from '../../adapters/cli/types.js'; +import type { BotSkillPolicy, SessionSkillManifest, SkillPackage } from './types.js'; +import { resolveSkillPolicy } from './policy.js'; + +export function resolveSessionSkillManifest(opts: { + sessionId: string; + cliId: CliId; + workingDir: string; + botPolicy: BotSkillPolicy | undefined; + globalProjectSkills?: 'off' | 'trusted' | 'all'; + globalDelivery?: 'auto' | 'prompt' | 'native'; + registrySkills: SkillPackage[]; + projectSkills: SkillPackage[]; + now?: () => string; +}): SessionSkillManifest | null { + const resolved = resolveSkillPolicy({ + registrySkills: opts.registrySkills, + projectSkills: opts.projectSkills, + globalProjectSkills: opts.globalProjectSkills, + globalDelivery: opts.globalDelivery, + botPolicy: opts.botPolicy, + workingDir: opts.workingDir, + }); + if (!resolved.enabled) return null; + return { + sessionId: opts.sessionId, + cliId: opts.cliId, + workingDir: opts.workingDir, + policyMode: resolved.mode, + delivery: resolved.delivery, + prioritySkills: resolved.prioritySkills, + diagnostics: resolved.diagnostics, + generatedAt: opts.now ? opts.now() : new Date().toISOString(), + }; +} diff --git a/src/core/skills/session-runtime.ts b/src/core/skills/session-runtime.ts new file mode 100644 index 00000000..13a4e618 --- /dev/null +++ b/src/core/skills/session-runtime.ts @@ -0,0 +1,43 @@ +import type { CliId } from '../../adapters/cli/types.js'; +import { readGlobalConfig } from '../../global-config.js'; +import { readSkillRegistry } from '../../services/skill-registry-store.js'; +import type { BotSkillPolicy, SessionSkillManifest } from './types.js'; +import { discoverProjectSkills } from './discovery.js'; +import { writeSessionSkillManifest } from './manifest-store.js'; +import { renderSkillCatalogBlock } from './prompt.js'; +import { resolveSessionSkillManifest } from './session-resolver.js'; + +export interface PreparedSessionSkillPrompt { + prompt: string; + manifest: SessionSkillManifest | null; +} + +export function prepareSessionSkillPrompt(opts: { + sessionId: string; + cliId: CliId; + workingDir: string; + prompt: string; + botPolicy: BotSkillPolicy | undefined; +}): PreparedSessionSkillPrompt { + if (!opts.botPolicy || opts.prompt.trim().length === 0) { + return { prompt: opts.prompt, manifest: null }; + } + const globalSkills = readGlobalConfig().skills; + const manifest = resolveSessionSkillManifest({ + sessionId: opts.sessionId, + cliId: opts.cliId, + workingDir: opts.workingDir, + botPolicy: opts.botPolicy, + globalProjectSkills: globalSkills?.trustProjectSkills, + globalDelivery: globalSkills?.delivery, + registrySkills: Object.values(readSkillRegistry().skills), + projectSkills: discoverProjectSkills(opts.workingDir), + }); + if (!manifest || manifest.prioritySkills.length === 0) return { prompt: opts.prompt, manifest }; + writeSessionSkillManifest(manifest); + if (opts.prompt.includes(' 0) { + ref = rest.slice(0, skillsIndex).join('/'); + path = rest.slice(skillsIndex).join('/'); + } else { + ref = rest[0]; + path = rest.slice(1).join('/') || undefined; + } + const pathParts = path?.split('/'); + if (parts[2] === 'blob' && pathParts?.[pathParts.length - 1]?.toLowerCase() === 'skill.md') { + path = pathParts.slice(0, -1).join('/') || undefined; + } + } + if (path) assertSafeGitSkillPath(path); + return { + kind: 'github', + value: raw, + github: { owner, repo, ...(path ? { path } : {}), ...(ref ? { ref } : {}) }, + }; +} + +export function parseSkillInstallSource(raw: string): ParsedSkillInstallSource { + if (raw.startsWith('github:')) { + const rest = raw.slice('github:'.length); + const parts = rest.split('/').filter(Boolean); + if (parts.length < 2) throw new Error('invalid_github_skill_source'); + const path = parts.slice(2).join('/') || undefined; + if (path) assertSafeGitSkillPath(path); + return { + kind: 'github', + value: raw, + github: { owner: parts[0], repo: parts[1], path }, + }; + } + assertNoGitUrlCredentials(raw); + const githubSource = parseGitHubBrowserUrl(raw); + if (githubSource) return githubSource; + if (raw.startsWith('git+') || raw.endsWith('.git') || raw.startsWith('git@')) { + return { kind: 'git', value: raw.replace(/^git\+/, '') }; + } + return { kind: 'local', value: raw }; +} + +export function githubToGitUrl(owner: string, repo: string): string { + return `https://github.com/${owner}/${repo}.git`; +} diff --git a/src/core/skills/types.ts b/src/core/skills/types.ts new file mode 100644 index 00000000..54478ebb --- /dev/null +++ b/src/core/skills/types.ts @@ -0,0 +1,54 @@ +import type { CliId } from '../../adapters/cli/types.js'; + +export type SkillSource = + | { type: 'bundled'; packageName: string } + | { type: 'user'; root: string } + | { type: 'project'; root: string } + | { type: 'admin'; root: string } + | { type: 'local-copy'; originalPath: string } + | { type: 'local-link'; path: string } + | { type: 'git'; url: string; path: string; ref?: string; commit?: string } + | { type: 'github'; owner: string; repo: string; path: string; ref?: string; commit?: string }; + +export interface SkillPackage { + id: string; + name: string; + displayName?: string; + description?: string; + version?: string; + tags: string[]; + rootDir: string; + entrypoint: string; + source: SkillSource; + checksum?: string; + installedAt?: string; + updatedAt?: string; +} + +export type SkillSelector = `skill:${string}`; + +export interface BotSkillPolicy { + include?: SkillSelector[]; +} + +export interface ResolvedSkill extends SkillPackage { + priorityReason: string; +} + +export interface SkillDiagnostic { + level: 'info' | 'warn' | 'error'; + code: string; + message: string; + skillName?: string; +} + +export interface SessionSkillManifest { + sessionId: string; + cliId: CliId; + workingDir: string; + policyMode: 'priority'; + delivery?: 'auto' | 'prompt' | 'native'; + prioritySkills: ResolvedSkill[]; + diagnostics: SkillDiagnostic[]; + generatedAt: string; +} diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 3f0090b3..8f16a3fd 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -13,6 +13,7 @@ import { installHook } from '../adapters/hook-installer.js'; import { hookCommandFor } from '../adapters/hook-command.js'; import { randomBytes } from 'node:crypto'; import { config } from '../config.js'; +import { readGlobalConfig } from '../global-config.js'; import * as sessionStore from '../services/session-store.js'; import { persistStreamCardState, rememberLastCliInput } from './session-manager.js'; import { fallbackTurnId } from './reply-target.js'; @@ -40,6 +41,8 @@ import { knownBotOpenIdsFromCrossRef, type BotMentionEntry } from '../utils/bot- import { emitSessionLifecycleHook, emitSessionStateTransitionHook } from '../services/session-lifecycle-hooks.js'; import { anchorUsageForDaemonSession, recordOwnershipForDaemonSession, recordUsageForDaemonSession, reconcileUsageForDaemonSession } from '../services/usage-ledger.js'; import type { CliId } from '../adapters/cli/types.js'; +import { prepareSessionSkillPrompt } from './skills/session-runtime.js'; +import { prepareSkillDelivery } from './skills/delivery.js'; import type { DaemonToWorker, WorkerToDaemon, Session, DisplayMode } from '../types.js'; import { sessionKey, sessionAnchorId, type DaemonSession } from './types.js'; import { claimPendingResponseCard, COMPLETED_REACTION_EMOJI_TYPE, markPendingResponseCardPatchedIfCurrent, syncPendingResponseState } from './pending-response.js'; @@ -1474,6 +1477,33 @@ export function forkWorker(ds: DaemonSession, prompt: string, resume = false): v const familyAdapter = createCliAdapterSync(botCfg.cliId, botCfg.cliPathOverride); if (familyAdapter.claudeStateJsonPath) ensureClaudeFolderTrust(cwd, familyAdapter.claudeStateJsonPath); + let skillPluginDir: string | undefined; + let skillReadonlyRoots: string[] | undefined; + if (!resume && prompt.trim().length > 0) { + const preparedSkills = prepareSessionSkillPrompt({ + sessionId: ds.session.sessionId, + cliId: botCfg.cliId, + workingDir: cwd, + prompt, + botPolicy: botCfg.skills, + }); + prompt = preparedSkills.prompt; + const delivery = prepareSkillDelivery(familyAdapter, preparedSkills.manifest, preparedSkills.manifest?.delivery ?? 'auto'); + skillPluginDir = delivery.pluginDir; + skillReadonlyRoots = delivery.readonlyRoots.length ? delivery.readonlyRoots : undefined; + for (const diagnostic of delivery.diagnostics) logger.warn(`[${t}] skill delivery: ${diagnostic}`); + if (delivery.fatal) { + const reason = delivery.diagnostics.join(', ') || 'unknown'; + const message = tr('worker.skill_delivery_failed', { reason }, botLocale(botCfg)); + logger.warn(`[${t}] Skill delivery blocked session start: ${reason}`); + void cb.sessionReply(sessionAnchorId(ds), message, undefined, ds.larkAppId, fallbackTurnId(ds, undefined)) + .catch((err) => logger.warn(`[${t}] Failed to notify skill delivery error: ${err?.message ?? err}`)); + void closeSession(ds.session.sessionId) + .catch((err) => logger.warn(`[${t}] Failed to close skill delivery error session: ${err?.message ?? err}`)); + return; + } + } + // Prepend ~/.botmux/bin to PATH so CLIs can call `botmux send` etc. // The wrapper script there is written by the daemon at startup. const botmuxBinDir = join(homedir(), '.botmux', 'bin'); @@ -1488,6 +1518,7 @@ export function forkWorker(ds: DaemonSession, prompt: string, resume = false): v CLAUDECODE: undefined, BOTMUX: '1', // Marker so user scripts/skills can detect a botmux-spawned CLI SESSION_DATA_DIR: config.session.dataDir, + BOTMUX_SESSION_ID: ds.session.sessionId, LARK_APP_ID: botCfg.larkAppId, LARK_APP_SECRET: botCfg.larkAppSecret, }, @@ -1546,6 +1577,8 @@ export function forkWorker(ds: DaemonSession, prompt: string, resume = false): v botOpenId: bot.botOpenId, locale: botLocale(botCfg), turnId: ds.currentReplyTarget?.turnId, + skillPluginDir, + skillReadonlyRoots, }; worker.send(initMsg); ds.initConfig = initMsg; diff --git a/src/dashboard.ts b/src/dashboard.ts index 1bbe2e5b..775716fc 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -44,6 +44,16 @@ import { listTeamReports, readTeamBoard, setTeamBoardEntry } from './services/te import type { CliId } from './adapters/cli/types.js'; import type { ConnectorDefinition } from './services/connector-store.js'; import { hd2dAssetPath, hd2dStatus, startHd2dDownload } from './dashboard/hd2d-assets.js'; +import { + readSkillRegistry, + removeInstalledSkill, + updateInstalledSkillAsync, +} from './services/skill-registry-store.js'; +import { redactGitUrlCredentials } from './core/skills/sources.js'; +import { loadBotConfigs } from './bot-registry.js'; +import type { BotSkillPolicy, SkillPackage } from './core/skills/types.js'; +import { analyzeSkillReferences, type SkillReferenceBot, type SkillReferenceSummary } from './core/skills/references.js'; +import { installDashboardSkill, parseDashboardSkillInstallRequest } from './dashboard/skill-install-request.js'; const SECRET_PATH = join(homedir(), '.botmux', '.dashboard-secret'); const TOKEN_PATH = join(homedir(), '.botmux', '.dashboard-token'); @@ -446,6 +456,129 @@ function dashboardUrlFor(token: string): string { return `http://${config.dashboard.externalHost}:${boundDashboardPort}/?t=${token}`; } +type SkillJobStatus = 'running' | 'succeeded' | 'failed'; +interface SkillJob { + id: string; + type: 'install' | 'update'; + status: SkillJobStatus; + createdAt: string; + updatedAt: string; + skill?: SkillPackage; + error?: string; +} + +const skillJobs = new Map(); +const MAX_SKILL_JOBS = 50; + +function publicSkillJob(job: SkillJob): Record { + return { + id: job.id, + type: job.type, + status: job.status, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + skill: job.skill ? sanitizeSkillForDashboard(job.skill) : undefined, + error: job.error, + }; +} + +function trimSkillJobs(): void { + const jobs = [...skillJobs.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + while (jobs.length > MAX_SKILL_JOBS) { + const old = jobs.shift(); + if (old) skillJobs.delete(old.id); + } +} + +function startSkillJob(type: SkillJob['type'], run: () => Promise): SkillJob { + const now = new Date().toISOString(); + const job: SkillJob = { + id: randomBytes(8).toString('hex'), + type, + status: 'running', + createdAt: now, + updatedAt: now, + }; + skillJobs.set(job.id, job); + trimSkillJobs(); + setImmediate(() => void (async () => { + try { + job.skill = await run(); + job.status = 'succeeded'; + } catch (err: any) { + job.error = redactGitUrlCredentials(err?.message ?? String(err)); + job.status = 'failed'; + } finally { + job.updatedAt = new Date().toISOString(); + trimSkillJobs(); + } + })()); + return job; +} + +function sanitizeSkillForDashboard(skill: SkillPackage): SkillPackage { + if (skill.source.type !== 'git') return skill; + return { + ...skill, + source: { ...skill.source, url: redactGitUrlCredentials(skill.source.url) }, + }; +} + +function dashboardSkillsPayload(): Record { + const globalSkills = readGlobalConfig().skills ?? {}; + return { + skills: Object.values(readSkillRegistry().skills) + .sort((a, b) => a.name.localeCompare(b.name)) + .map(sanitizeSkillForDashboard), + trustProjectSkills: globalSkills.trustProjectSkills ?? 'off', + delivery: globalSkills.delivery ?? 'auto', + }; +} + +function mergeSkillReferenceBot(refs: Map, ref: SkillReferenceBot): void { + const current = refs.get(ref.larkAppId); + if (!current) { + refs.set(ref.larkAppId, { ...ref }); + return; + } + current.direct ||= ref.direct; +} + +async function dashboardSkillReferences(skillName: string): Promise { + const refs = new Map(); + try { + for (const ref of analyzeSkillReferences(skillName, { + bots: loadBotConfigs(), + }).bots) mergeSkillReferenceBot(refs, ref); + } catch { + // Fall back to online daemon data below when the dashboard process cannot + // read persistent bot config. + } + + const onlineBots = [...registry.list()].sort((a, b) => a.botIndex - b.botIndex); + const onlineRefs = await Promise.all(onlineBots.map(async d => { + try { + const r = await fetch(`http://127.0.0.1:${d.ipcPort}/api/bot-default-oncall`, { + signal: AbortSignal.timeout(1_500), + }); + if (!r.ok) return null; + const j = await r.json() as any; + const [ref] = analyzeSkillReferences(skillName, { + bots: [{ larkAppId: d.larkAppId, botName: d.botName ?? j.botName ?? d.larkAppId, skills: j.skills as BotSkillPolicy | null | undefined }], + }).bots; + return ref ?? null; + } catch { + return null; + } + })); + for (const ref of onlineRefs) { + if (ref) mergeSkillReferenceBot(refs, ref); + } + return { + bots: [...refs.values()].sort((a, b) => a.botName.localeCompare(b.botName)), + }; +} + const server = createServer(async (req, res) => { try { const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); @@ -665,6 +798,98 @@ const server = createServer(async (req, res) => { return jsonRes(res, 200, { ok: true, settings: resolveDashboardSettings() }); } + if (req.method === 'GET' && url.pathname === '/api/skills') { + return jsonRes(res, 200, dashboardSkillsPayload()); + } + + if (req.method === 'PUT' && url.pathname === '/api/skills/global') { + let parsed: unknown; + try { + parsed = await readJsonBody(req); + } catch { + return jsonRes(res, 400, { ok: false, error: 'bad_json' }); + } + const body = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}; + if (!('trustProjectSkills' in body) && !('delivery' in body)) return jsonRes(res, 400, { ok: false, error: 'empty_patch' }); + const patch: NonNullable['skills']> = {}; + if ('trustProjectSkills' in body) { + const raw = body.trustProjectSkills; + const trustProjectSkills = raw === 'trusted' ? 'all' : raw; + if (trustProjectSkills !== 'off' && trustProjectSkills !== 'all') { + return jsonRes(res, 400, { ok: false, error: 'invalid_trustProjectSkills' }); + } + patch.trustProjectSkills = trustProjectSkills; + } + if ('delivery' in body) { + const delivery = body.delivery; + if (delivery !== 'auto' && delivery !== 'prompt' && delivery !== 'native') { + return jsonRes(res, 400, { ok: false, error: 'invalid_delivery' }); + } + patch.delivery = delivery; + } + const currentSkills = readGlobalConfig().skills ?? {}; + mergeGlobalConfig({ skills: { ...currentSkills, ...patch } }); + return jsonRes(res, 200, { ok: true, ...dashboardSkillsPayload() }); + } + + if (req.method === 'POST' && url.pathname === '/api/skills/install') { + let parsed: unknown; + try { + parsed = await readJsonBody(req); + } catch { + return jsonRes(res, 400, { ok: false, error: 'bad_json' }); + } + const body = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}; + try { + const installRequest = parseDashboardSkillInstallRequest(body); + const job = startSkillJob('install', () => installDashboardSkill(installRequest)); + return jsonRes(res, 202, { ok: true, job: publicSkillJob(job) }); + } catch (err: any) { + return jsonRes(res, 400, { ok: false, error: redactGitUrlCredentials(err?.message ?? String(err)) }); + } + } + + let mSkillJob: RegExpMatchArray | null; + if (req.method === 'GET' && (mSkillJob = url.pathname.match(/^\/api\/skills\/jobs\/([^/]+)$/))) { + const job = skillJobs.get(decodeURIComponent(mSkillJob[1])); + if (!job) return jsonRes(res, 404, { ok: false, error: 'job_not_found' }); + return jsonRes(res, 200, { ok: true, job: publicSkillJob(job) }); + } + + let mSkillUpdate: RegExpMatchArray | null; + if (req.method === 'POST' && (mSkillUpdate = url.pathname.match(/^\/api\/skills\/([^/]+)\/update$/))) { + const name = decodeURIComponent(mSkillUpdate[1]); + if (!readSkillRegistry().skills[name]) return jsonRes(res, 400, { ok: false, error: 'skill_not_installed' }); + const job = startSkillJob('update', async () => { + const r = await updateInstalledSkillAsync(name); + if (!r.ok) throw new Error(r.reason); + return r.skill; + }); + return jsonRes(res, 202, { ok: true, job: publicSkillJob(job) }); + } + + let mSkillDelete: RegExpMatchArray | null; + if (req.method === 'DELETE' && (mSkillDelete = url.pathname.match(/^\/api\/skills\/([^/]+)$/))) { + const name = decodeURIComponent(mSkillDelete[1]); + const force = url.searchParams.get('force') === '1'; + if (!readSkillRegistry().skills[name]) return jsonRes(res, 400, { ok: false, error: 'skill_not_installed' }); + const refs = await dashboardSkillReferences(name); + if (!force && refs.bots.length > 0) { + return jsonRes(res, 409, { + ok: false, + error: 'skill_in_use', + affectedBots: refs.bots, + }); + } + const r = removeInstalledSkill(name); + if (!r.ok) return jsonRes(res, 400, { ok: false, error: r.reason }); + return jsonRes(res, 200, { + ok: true, + affectedBots: refs.bots, + ...dashboardSkillsPayload(), + }); + } + if (await handleConnectorApi(req, res, url)) { return; } @@ -1169,6 +1394,7 @@ const server = createServer(async (req, res) => { restrictGrantCommands: j.restrictGrantCommands === true, messageQuotaDefaultLimit: typeof j.messageQuotaDefaultLimit === 'number' ? j.messageQuotaDefaultLimit : null, p2pMode: j.p2pMode === 'chat' ? 'chat' : 'thread', + skills: j.skills && typeof j.skills === 'object' ? j.skills : null, }; } catch (e: any) { return { larkAppId: d.larkAppId, botName: d.botName, online: true, error: e?.message ?? String(e) }; @@ -1193,6 +1419,24 @@ const server = createServer(async (req, res) => { return; } + // PUT /api/bots/:appId/skills — proxy to that bot's daemon. Body accepts + // `{ action:'attach'|'detach', name }` or `{ action:'set', policy|null }`. + let mBotSkills: RegExpMatchArray | null; + if (req.method === 'PUT' && (mBotSkills = url.pathname.match(/^\/api\/bots\/([^/]+)\/skills$/))) { + const appId = decodeURIComponent(mBotSkills[1]); + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const raw = Buffer.concat(chunks).toString('utf8') || '{}'; + const upstream = await proxyToDaemon(appId, `/api/bot-skills`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: raw, + }); + res.writeHead(upstream.status, { 'content-type': 'application/json' }); + res.end(await upstream.text()); + return; + } + // PUT /api/bots/:appId/brand-label — proxy to that bot's daemon. Body // `{ brandLabel: string | null }` (string '' = off, null = default). let mBotBrand: RegExpMatchArray | null; diff --git a/src/dashboard/skill-install-request.ts b/src/dashboard/skill-install-request.ts new file mode 100644 index 00000000..1308da56 --- /dev/null +++ b/src/dashboard/skill-install-request.ts @@ -0,0 +1,76 @@ +import { + installGitSkillAsync, + installLocalSkill, +} from '../services/skill-registry-store.js'; +import { + assertSafeGitSkillPath, + githubToGitUrl, + parseSkillInstallSource, +} from '../core/skills/sources.js'; +import type { SkillPackage, SkillSource } from '../core/skills/types.js'; + +const AUTO_LINK_SKILL_ROOT_MARKERS = new Set([ + '.agents', + '.botmux', + '.claude', + '.codex', + '.cursor', + '.gemini', + '.opencode', +]); + +export type DashboardSkillInstallRequest = + | { kind: 'local'; value: string; link: boolean } + | { kind: 'git'; url: string; path: string; ref?: string } + | { kind: 'github'; owner: string; repo: string; path: string; ref?: string }; + +export function shouldAutoLinkLocalSkillPath(rawPath: string): boolean { + const normalized = rawPath.replace(/\\/g, '/'); + const parts = normalized.split('/').filter(Boolean); + return parts.some((part, index) => ( + AUTO_LINK_SKILL_ROOT_MARKERS.has(part) + && parts.slice(index + 1).includes('skills') + )); +} + +export function parseDashboardSkillInstallRequest(body: Record): DashboardSkillInstallRequest { + const source = typeof body.source === 'string' ? body.source.trim() : ''; + if (!source) throw new Error('source_required'); + const parsedSource = parseSkillInstallSource(source); + if (parsedSource.kind === 'local') { + return { kind: 'local', value: parsedSource.value, link: body.link === true || shouldAutoLinkLocalSkillPath(parsedSource.value) }; + } + const parsedRef = parsedSource.github?.ref; + const ref = typeof body.ref === 'string' && body.ref.trim() ? body.ref.trim() : parsedRef; + if (parsedSource.kind === 'git') { + const path = typeof body.path === 'string' && body.path.trim() ? body.path.trim() : undefined; + if (!path) throw new Error('path_required'); + assertSafeGitSkillPath(path); + return { kind: 'git', url: parsedSource.value, path, ref }; + } + const gh = parsedSource.github; + const path = typeof body.path === 'string' && body.path.trim() ? body.path.trim() : gh?.path; + if (!gh || !path) throw new Error('path_required'); + assertSafeGitSkillPath(path); + return { kind: 'github', owner: gh.owner, repo: gh.repo, path, ref }; +} + +export async function installDashboardSkill(request: DashboardSkillInstallRequest): Promise { + if (request.kind === 'local') return installLocalSkill(request.value, { link: request.link }); + if (request.kind === 'git') { + return installGitSkillAsync({ url: request.url, path: request.path, ref: request.ref }); + } + const sourceOverride: SkillSource = { + type: 'github', + owner: request.owner, + repo: request.repo, + path: request.path, + ...(request.ref ? { ref: request.ref } : {}), + }; + return installGitSkillAsync({ + url: githubToGitUrl(request.owner, request.repo), + path: request.path, + ref: request.ref, + sourceOverride, + }); +} diff --git a/src/dashboard/web/app.ts b/src/dashboard/web/app.ts index 3ff2acc1..2b94476d 100644 --- a/src/dashboard/web/app.ts +++ b/src/dashboard/web/app.ts @@ -5,6 +5,7 @@ import { renderSessionsPage } from './sessions.js'; import { renderSchedulesPage } from './schedules.js'; import { renderGroupsPage } from './groups.js'; import { renderBotDefaultsPage } from './bot-defaults.js'; +import { renderSkillsPage } from './skills.js'; import { renderRolesPage } from './roles.js'; import { renderTeamFederationPage, renderTeamManagePage } from './team-federation.js'; import { renderConnectorsPage } from './connectors.js'; @@ -31,7 +32,7 @@ let publicReadOnly = false; // Management pages are token-gated end-to-end (no public GET) — a read-only // visitor must not reach them. `data-route` values from index.html's nav. -const MANAGE_ROUTES = ['roles', 'bot-defaults', 'team', 'connectors']; +const MANAGE_ROUTES = ['roles', 'bot-defaults', 'skills', 'team', 'connectors']; // ── Auth-expiry overlay ────────────────────────────────────────────────────── // Shown only when the dashboard token was rotated WHILE public read-only is off @@ -247,6 +248,7 @@ function route() { else if (hash.startsWith('#/groups')) renderGroupsPage(root); else if (hash.startsWith('#/settings')) void renderSettingsPage(root); else if (hash.startsWith('#/bot-defaults')) renderBotDefaultsPage(root); + else if (hash.startsWith('#/skills')) void renderSkillsPage(root); else if (hash.startsWith('#/connectors')) renderConnectorsPage(root); else if (hash.startsWith('#/team/manage')) renderTeamManagePage(root); else if (hash.startsWith('#/team')) renderTeamFederationPage(root); diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index c4597808..bb2fff42 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -18,6 +18,7 @@ const zh: DashboardMessages = { 'nav.schedules': '定时', 'nav.settings': '设置', 'nav.botDefaults': 'Bot 配置', + 'nav.skills': 'Skills', 'status.live': '实时连接', 'status.disconnected': '连接断开', 'status.system': '系统', @@ -457,6 +458,74 @@ const zh: DashboardMessages = { 'settings.autoRestartHelp': '默认关闭,需先开启自动更新。自动更新装到新版本后,若没有进行中的会话则自动重启以生效(撞上忙碌会话则跳到次日)。', 'settings.autoUpdateLocalDev': '当前为本地开发安装(从源码运行),自动更新不可用。', 'settings.maintenanceTime': '时间', + 'skills.title': 'Skill 管理', + 'skills.subtitle': '管理 botmux 的 CLI 无关 Skill 资产,并按 bot 选择优先披露。', + 'skills.installed': '已安装 Skills', + 'skills.installedHelp': '本机 registry 中可被 bot 优先披露的 Skill 资产。', + 'skills.overviewTitle': '按 Bot 组织 Skill 资产', + 'skills.overviewBody': '全局 Skill 注入配置控制会话注入策略;每个 bot 只维护自己的优先披露列表。', + 'skills.metricInstalled': '已安装 Skills', + 'skills.metricBots': '已配置 Bot', + 'skills.metricAttached': '优先引用', + 'skills.install': '安装 Skill', + 'skills.installInfoLabel': '安装位置说明', + 'skills.installInfo': '注册: ~/.botmux/skills/registry.json\n安装: ~/.botmux/skills/store/技能名;Git 缓存: ~/.botmux/skills/sources\n隔离: 不写入 Codex/Claude 全局目录;本机 CLI Skill 直接引用原地址', + 'skills.source': '导入地址', + 'skills.sourcePlaceholder': '粘贴 GitHub Skill 页面、Git URL 或本机 Skill 目录', + 'skills.sourceHelpRemoteLabel': 'GitHub / Git: ', + 'skills.sourceHelpRemote': '可粘贴 Skill 目录页面或仓库地址;仓库地址需填写仓库内路径。', + 'skills.sourceHelpLocalLabel': '本机 CLI Skill 目录: ', + 'skills.sourceHelpLocal': '直接引用原地址,不复制到 botmux store。', + 'skills.path': '仓库内路径(可选)', + 'skills.pathPlaceholder': '留空则使用 GitHub URL 中的路径', + 'skills.ref': 'Ref(可选)', + 'skills.refPlaceholder': '留空则使用 GitHub URL 中的分支或默认 HEAD', + 'skills.link': '本地链接(不复制)', + 'skills.installSubmit': '安装', + 'skills.update': '更新', + 'skills.remove': '删除', + 'skills.pageStatus': '{page} / {pages}', + 'skills.prevPage': '上一页', + 'skills.nextPage': '下一页', + 'skills.empty': '暂无已安装 skill。可以从本机目录、Git 或 GitHub 安装。', + 'skills.globalDefaults': '全局 Skill 注入配置', + 'skills.globalProject': '工作区 Skill', + 'skills.globalProjectOff': '忽略工作区 Skill', + 'skills.globalProjectOffHelp': '仅读取全局 CLI Skill 和 botmux Skill', + 'skills.globalProjectAll': '读取工作区 Skill', + 'skills.globalProjectAllHelp': '读取 .agents/skills 与\n.botmux/skills', + 'skills.globalDelivery': 'Skill 注入方式', + 'skills.bots': 'Bot Skill 配置', + 'skills.botsHelp': '为每个 bot 选择最需要优先披露的 Skill。', + 'skills.botCount': '{count} 个 Bot', + 'skills.scrollBotsPrev': '向左查看 Bot', + 'skills.scrollBotsNext': '向右查看 Bot', + 'skills.skillCount': '{count} 个 Skill', + 'skills.attach': '加入优先披露', + 'skills.detach': '移除', + 'skills.detachNamed': '移除 {skill}', + 'skills.delivery': '注入方式', + 'skills.deliveryAuto': '自动', + 'skills.deliveryAutoHelp': '优先用 CLI 原生通道;不支持时自动改用提示词注入', + 'skills.deliveryPrompt': '提示词', + 'skills.deliveryPromptHelp': '把 Skill 清单写入会话提示词,兼容所有 CLI', + 'skills.deliveryNative': '原生', + 'skills.deliveryNativeHelp': '只交给 CLI 自己的 Skill 机制,不做提示词兜底', + 'skills.projectMode': '工作区 Skills', + 'skills.projectDefault': '跟随全局', + 'skills.projectDefaultHelp': '使用左侧项目 Skill 默认值', + 'skills.projectOff': '不读取', + 'skills.projectOffHelp': '这个 bot 不读取工作区 skill', + 'skills.projectAll': '读取', + 'skills.projectAllHelp': '这个 bot 加入工作区 skill 候选', + 'skills.priority': '优先 skills', + 'skills.noPriority': '未配置,保持 CLI 默认行为', + 'skills.dangling': '未安装', + 'skills.removeInUse': 'Skill "{skill}" 仍被以下配置引用:{refs}。删除只会移除 registry 文件,不会自动清理引用。继续删除?', + 'skills.saved': '已保存', + 'skills.failed': '失败', + 'skills.jobRunning': '处理中...', + 'skills.refresh': '刷新', 'botDefaults.title': '数字员工档案', 'botDefaults.subtitle': '每位员工的默认行为:oncall、主动开工、人设角色、卡片与授权。', 'botDefaults.metaOnline': '在线 · daemon 正常', @@ -756,6 +825,7 @@ const en: DashboardMessages = { 'nav.schedules': 'Schedules', 'nav.settings': 'Settings', 'nav.botDefaults': 'Bot Defaults', + 'nav.skills': 'Skills', 'status.live': 'Live', 'status.disconnected': 'Disconnected', 'status.system': 'System', @@ -1195,6 +1265,74 @@ const en: DashboardMessages = { 'settings.autoRestartHelp': 'Off by default; requires auto-update. After auto-update installs a newer version, restart to apply it if no session is in progress (busy ⇒ slips to the next day).', 'settings.autoUpdateLocalDev': 'This is a local-dev install (running from source); auto-update is unavailable.', 'settings.maintenanceTime': 'Time', + 'skills.title': 'Skill Management', + 'skills.subtitle': 'Manage CLI-neutral botmux Skill assets and choose priority disclosure per bot.', + 'skills.installed': 'Installed Skills', + 'skills.installedHelp': 'Skill assets in the local registry that bots can disclose first.', + 'skills.overviewTitle': 'Organize Skill assets by bot', + 'skills.overviewBody': 'Global Skill delivery settings control session injection; each bot only manages its priority disclosure list.', + 'skills.metricInstalled': 'Installed Skills', + 'skills.metricBots': 'Configured Bots', + 'skills.metricAttached': 'Priority refs', + 'skills.install': 'Install Skill', + 'skills.installInfoLabel': 'Install location details', + 'skills.installInfo': 'Registry: ~/.botmux/skills/registry.json\nInstall: ~/.botmux/skills/store/skill-name; Git cache: ~/.botmux/skills/sources\nIsolation: Codex/Claude global directories are not modified; local CLI Skills are referenced in place', + 'skills.source': 'Import Address', + 'skills.sourcePlaceholder': 'Paste a GitHub Skill page, Git URL, or local Skill directory', + 'skills.sourceHelpRemoteLabel': 'GitHub / Git: ', + 'skills.sourceHelpRemote': 'Paste a Skill directory page or repository URL; repository URLs need a repo path.', + 'skills.sourceHelpLocalLabel': 'Local CLI Skill directory: ', + 'skills.sourceHelpLocal': 'Referenced in place without copying into the botmux store.', + 'skills.path': 'Repo Path (optional)', + 'skills.pathPlaceholder': 'Blank uses the path from the GitHub URL', + 'skills.ref': 'Ref (optional)', + 'skills.refPlaceholder': 'Blank uses the GitHub URL ref or default HEAD', + 'skills.link': 'Local link (no copy)', + 'skills.installSubmit': 'Install', + 'skills.update': 'Update', + 'skills.remove': 'Remove', + 'skills.pageStatus': '{page} / {pages}', + 'skills.prevPage': 'Previous page', + 'skills.nextPage': 'Next page', + 'skills.empty': 'No installed skills yet. Install from a local directory, Git, or GitHub.', + 'skills.globalDefaults': 'Global Skill Delivery', + 'skills.globalProject': 'Workspace Skills', + 'skills.globalProjectOff': 'Ignore workspace Skills', + 'skills.globalProjectOffHelp': 'Read global CLI Skills and botmux Skills only', + 'skills.globalProjectAll': 'Read workspace Skills', + 'skills.globalProjectAllHelp': 'Read .agents/skills and\n.botmux/skills', + 'skills.globalDelivery': 'Skill Delivery', + 'skills.bots': 'Bot Skill Settings', + 'skills.botsHelp': 'Choose the Skills each bot should disclose first.', + 'skills.botCount': '{count} Bots', + 'skills.scrollBotsPrev': 'Scroll bots left', + 'skills.scrollBotsNext': 'Scroll bots right', + 'skills.skillCount': '{count} Skills', + 'skills.attach': 'Add to priority', + 'skills.detach': 'Remove', + 'skills.detachNamed': 'Remove {skill}', + 'skills.delivery': 'Injection', + 'skills.deliveryAuto': 'Auto', + 'skills.deliveryAutoHelp': 'Use native CLI delivery first; fall back to Prompt when unsupported', + 'skills.deliveryPrompt': 'Prompt', + 'skills.deliveryPromptHelp': 'Inject the Skill catalog into the session prompt; works with every CLI', + 'skills.deliveryNative': 'Native', + 'skills.deliveryNativeHelp': 'Use only the CLI native Skill mechanism; no Prompt fallback', + 'skills.projectMode': 'Workspace Skills', + 'skills.projectDefault': 'Follow global', + 'skills.projectDefaultHelp': 'Use the project default on the left', + 'skills.projectOff': 'Do not read', + 'skills.projectOffHelp': 'This bot ignores workspace skills', + 'skills.projectAll': 'Read', + 'skills.projectAllHelp': 'This bot includes workspace skill candidates', + 'skills.priority': 'Priority skills', + 'skills.noPriority': 'Not configured, keeping CLI default behavior', + 'skills.dangling': 'Not installed', + 'skills.removeInUse': 'Skill "{skill}" is still referenced by: {refs}. Removing it deletes the registry package but does not automatically clean references. Continue?', + 'skills.saved': 'Saved', + 'skills.failed': 'Failed', + 'skills.jobRunning': 'Working...', + 'skills.refresh': 'Refresh', 'botDefaults.title': 'Bot Profiles', 'botDefaults.subtitle': 'Per-bot defaults: oncall, proactive start, persona role, cards and grants.', 'botDefaults.metaOnline': 'Online · daemon healthy', diff --git a/src/dashboard/web/index.html b/src/dashboard/web/index.html index 1effd5ac..7ee5706d 100644 --- a/src/dashboard/web/index.html +++ b/src/dashboard/web/index.html @@ -26,6 +26,7 @@ 定时 角色 Bot 配置 + Skills 团队 Webhook 办公室 diff --git a/src/dashboard/web/skills.ts b/src/dashboard/web/skills.ts new file mode 100644 index 00000000..6c13dbf5 --- /dev/null +++ b/src/dashboard/web/skills.ts @@ -0,0 +1,575 @@ +import { botAvatarHtml, escapeHtml, loadingHtml, t } from './ui.js'; + +interface SkillRow { + name: string; + description?: string; + tags?: string[]; + source?: Record; +} + +interface BotRow { + larkAppId: string; + botName?: string; + online?: boolean; + error?: string; + skills?: SkillPolicy | null; +} + +interface SkillPolicy { + include?: string[]; +} + +interface DashboardRequestError extends Error { + status?: number; + body?: any; +} + +interface SkillJob { + id: string; + status: 'running' | 'succeeded' | 'failed'; + error?: string; +} + +let state: { + skills: SkillRow[]; + bots: BotRow[]; + trustProjectSkills: 'off' | 'all'; + delivery: 'auto' | 'prompt' | 'native'; +} = { skills: [], bots: [], trustProjectSkills: 'off', delivery: 'auto' }; +let loadError: string | null = null; +const INSTALLED_SKILLS_ROWS_PER_PAGE = 2; +let installedSkillsPage = 0; + +function pageHtml(): string { + return `
+
+
+

${t('nav.skills')}

+

${t('skills.title')}

+

${t('skills.subtitle')}

+
+ +
+
+
`; +} + +function sourceLabel(skill: SkillRow): string { + const source = skill.source ?? {}; + if (source.type === 'github') return `github:${source.owner}/${source.repo}/${source.path ?? ''}`; + if (source.type === 'git') return `${source.url ?? 'git'}#${source.path ?? ''}`; + if (source.type === 'local-link') return 'local-link'; + if (source.type === 'local-copy') return 'local-copy'; + return String(source.type ?? 'unknown'); +} + +function priorityNames(policy?: SkillPolicy | null): string[] { + return (policy?.include ?? []) + .filter((item) => item.startsWith('skill:')) + .map((item) => item.slice('skill:'.length)); +} + +function policyReferenceCount(policy?: SkillPolicy | null): number { + return priorityNames(policy).length; +} + +function policyConfigured(policy?: SkillPolicy | null): boolean { + return priorityNames(policy).length > 0; +} + +function installedSkillNames(): Set { + return new Set(state.skills.map(skill => skill.name)); +} + +function referencingBotLabels(skillName: string): string[] { + return state.bots + .filter(bot => priorityNames(bot.skills).includes(skillName)) + .map(bot => bot.botName ?? bot.larkAppId); +} + +function renderInstallForm(): string { + return `
+
+

${t('skills.install')}

+ + + ${t('skills.installInfo')} + +
+
+ +
+ ${t('skills.sourceHelpRemoteLabel')}${t('skills.sourceHelpRemote')} + ${t('skills.sourceHelpLocalLabel')}${t('skills.sourceHelpLocal')} +
+ + +
+
+ + +
+
`; +} + +function renderInstalledSkills(): string { + if (state.skills.length === 0) return `

${t('skills.empty')}

`; + clampInstalledSkillsPage(); + const pageSize = installedSkillsPageSize(); + const start = installedSkillsPage * pageSize; + const visibleSkills = state.skills.slice(start, start + pageSize); + return `
${visibleSkills.map(skill => ` +
+
+ ${escapeHtml(skill.name)} + ${skill.description ? `

${escapeHtml(skill.description)}

` : ''} + ${escapeHtml(sourceLabel(skill))} +
+
+ + +
+
`).join('')}
`; +} + +function installedSkillsColumnCount(): number { + const width = typeof window === 'undefined' ? 1440 : window.innerWidth; + if (width >= 1600) return 4; + if (width <= 620) return 1; + if (width <= 980) return 2; + return 3; +} + +function installedSkillsPageSize(): number { + return installedSkillsColumnCount() * INSTALLED_SKILLS_ROWS_PER_PAGE; +} + +function installedSkillsPageCount(): number { + return Math.max(1, Math.ceil(state.skills.length / installedSkillsPageSize())); +} + +function clampInstalledSkillsPage(): void { + installedSkillsPage = Math.min(Math.max(0, installedSkillsPage), installedSkillsPageCount() - 1); +} + +function renderInstalledToolbar(): string { + clampInstalledSkillsPage(); + const count = `${t('skills.skillCount', { count: state.skills.length })}`; + const pageCount = installedSkillsPageCount(); + if (pageCount <= 1) return count; + return `
+ ${count} +
+ + ${t('skills.pageStatus', { page: installedSkillsPage + 1, pages: pageCount })} + +
+
`; +} + +function renderGlobalPolicy(): string { + const deliveryOptions = [ + ['auto', t('skills.deliveryAuto'), t('skills.deliveryAutoHelp')], + ['prompt', t('skills.deliveryPrompt'), t('skills.deliveryPromptHelp')], + ['native', t('skills.deliveryNative'), t('skills.deliveryNativeHelp')], + ]; + return `
+

${t('skills.globalDefaults')}

+
+ ${t('skills.globalProject')} +
+ ${[ + ['off', t('skills.globalProjectOff'), t('skills.globalProjectOffHelp')], + ['all', t('skills.globalProjectAll'), t('skills.globalProjectAllHelp')], + ].map(([value, label, help]) => ``).join('')} +
+
+
+ ${t('skills.globalDelivery')} +
+ ${deliveryOptions.map(([value, label, help]) => ``).join('')} +
+
+
`; +} + +function renderSkillPicker(bot: BotRow): string { + const attached = new Set(priorityNames(bot.skills)); + const options = state.skills.filter(skill => !attached.has(skill.name)); + if (options.length === 0) return ``; + return ` + `; +} + +function renderBotPolicy(bot: BotRow): string { + if (bot.error) { + return `
+
${botAvatarHtml({ name: bot.botName ?? bot.larkAppId, larkAppId: bot.larkAppId, size: 'sm' })} + ${escapeHtml(bot.botName ?? bot.larkAppId)}
+

${escapeHtml(bot.error)}

+
`; + } + const names = priorityNames(bot.skills); + const installed = installedSkillNames(); + return `
+
+ ${botAvatarHtml({ name: bot.botName ?? bot.larkAppId, larkAppId: bot.larkAppId, size: 'sm', dot: 'ok' })} +
${escapeHtml(bot.botName ?? bot.larkAppId)}${escapeHtml(bot.larkAppId)}
+ ${t('skills.skillCount', { count: names.length })} +
+
+

${t('skills.priority')}

+ ${names.length === 0 + ? `

${t('skills.noPriority')}

` + : `
${names.map(name => { + const dangling = !installed.has(name); + return ` + ${escapeHtml(name)}${dangling ? `${t('skills.dangling')}` : ''} + + `; + }).join('')}
`} +
${renderSkillPicker(bot)}
+
+ +
`; +} + +function attachedSkillRefCount(): number { + return state.bots.reduce((sum, bot) => sum + policyReferenceCount(bot.skills), 0); +} + +function configuredBotCount(): number { + return state.bots.filter(bot => policyConfigured(bot.skills)).length; +} + +function renderOverview(): string { + return `
+
+

${t('skills.overviewTitle')}

+

${t('skills.overviewBody')}

+
+
+ ${t('skills.metricInstalled')}${state.skills.length} + ${t('skills.metricBots')}${configuredBotCount()}/${state.bots.length} + ${t('skills.metricAttached')}${attachedSkillRefCount()} +
+
`; +} + +function renderBotRailActions(): string { + const count = `${t('skills.botCount', { count: state.bots.length })}`; + if (state.bots.length <= 3) return count; + return `
+ + ${count} + +
`; +} + +function renderBody(): string { + if (loadError) return `

${escapeHtml(loadError)}

`; + return `
+ +
+ ${renderOverview()} +
+
+
+

${t('skills.bots')}

+

${t('skills.botsHelp')}

+
+ ${renderBotRailActions()} +
+
${state.bots.map(renderBotPolicy).join('')}
+
+
+
+
+

${t('skills.installed')}

+

${t('skills.installedHelp')}

+
+ ${renderInstalledToolbar()} +
+ ${renderInstalledSkills()} +
+
+
`; +} + +async function loadData(): Promise { + try { + const [skillsRes, botsRes] = await Promise.all([ + fetch('/api/skills'), + fetch('/api/bots'), + ]); + const skillsBody = await skillsRes.json().catch(() => ({})); + const botsBody = await botsRes.json().catch(() => ({})); + if (!skillsRes.ok) throw new Error(skillsBody?.error ?? `skills HTTP ${skillsRes.status}`); + if (!botsRes.ok) throw new Error(botsBody?.error ?? `bots HTTP ${botsRes.status}`); + state = { + skills: Array.isArray(skillsBody.skills) ? skillsBody.skills : [], + bots: Array.isArray(botsBody.bots) ? botsBody.bots : [], + trustProjectSkills: skillsBody.trustProjectSkills === 'all' ? 'all' : 'off', + delivery: skillsBody.delivery === 'prompt' || skillsBody.delivery === 'native' ? skillsBody.delivery : 'auto', + }; + clampInstalledSkillsPage(); + loadError = null; + } catch (err: any) { + loadError = err?.message ?? String(err); + } +} + +async function jsonRequest(url: string, init: RequestInit): Promise { + const r = await fetch(url, { + ...init, + headers: { 'content-type': 'application/json', ...(init.headers ?? {}) }, + }); + const body = await r.json().catch(() => ({})); + if (!r.ok || body.ok === false) { + const err = new Error(body?.error ?? `HTTP ${r.status}`) as DashboardRequestError; + err.status = r.status; + err.body = body; + throw err; + } + return body; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export async function renderSkillsPage(root: HTMLElement): Promise { + root.innerHTML = pageHtml(); + const bodyEl = root.querySelector('#skills-body')!; + const refreshBtn = root.querySelector('#skills-refresh')!; + + async function refresh(): Promise { + bodyEl.innerHTML = loadingHtml(); + await loadData(); + rerender(); + } + + function status(scope?: HTMLElement | null): HTMLElement | null { + return scope?.querySelector('[data-skills-status], [data-bot-status]') ?? null; + } + + function showStatus(el: HTMLElement | null, text: string, ok: boolean): void { + if (!el) return; + el.textContent = text; + el.className = `oncall-status ${ok ? 'hint-ok' : 'hint-warn-inline'}`; + } + + function setChoiceButtonsDisabled(selector: string, disabled: boolean): void { + bodyEl.querySelectorAll(selector).forEach(button => { button.disabled = disabled; }); + } + + function syncChoiceButtons(selector: string, datasetKey: string, selectedValue: string): void { + bodyEl.querySelectorAll(selector).forEach(button => { + const selected = button.dataset[datasetKey] === selectedValue; + button.classList.toggle('selected', selected); + button.setAttribute('aria-pressed', selected ? 'true' : 'false'); + }); + } + + function rerender(): void { + bodyEl.innerHTML = renderBody(); + wire(); + } + + async function waitForSkillJob(job: SkillJob, statusEl: HTMLElement | null): Promise { + let current = job; + showStatus(statusEl, t('skills.jobRunning'), true); + for (;;) { + if (current.status === 'succeeded') { + showStatus(statusEl, t('skills.saved'), true); + await refresh(); + return; + } + if (current.status === 'failed') { + throw new Error(current.error ?? 'job_failed'); + } + await sleep(800); + const body = await jsonRequest(`/api/skills/jobs/${encodeURIComponent(current.id)}`, { method: 'GET' }); + current = body.job as SkillJob; + } + } + + function wire(): void { + bodyEl.querySelector('[data-action="install"]')?.addEventListener('click', async () => { + const panel = bodyEl.querySelector('.skills-install-panel'); + const statusEl = status(panel); + const button = bodyEl.querySelector('[data-action="install"]'); + const source = bodyEl.querySelector('[data-install="source"]')?.value.trim() ?? ''; + const path = bodyEl.querySelector('[data-install="path"]')?.value.trim() ?? ''; + const ref = bodyEl.querySelector('[data-install="ref"]')?.value.trim() ?? ''; + try { + if (button) button.disabled = true; + const body = await jsonRequest('/api/skills/install', { + method: 'POST', + body: JSON.stringify({ source, path: path || undefined, ref: ref || undefined }), + }); + await waitForSkillJob(body.job as SkillJob, statusEl); + } catch (err: any) { + showStatus(statusEl, `${t('skills.failed')}: ${err?.message ?? err}`, false); + } finally { + if (button) button.disabled = false; + } + }); + + bodyEl.querySelectorAll('[data-global-project-value]').forEach(button => button.addEventListener('click', async () => { + const next = button.dataset.globalProjectValue === 'all' ? 'all' : 'off'; + if (state.trustProjectSkills === next) return; + try { + setChoiceButtonsDisabled('[data-global-project-value]', true); + const body = await jsonRequest('/api/skills/global', { + method: 'PUT', + body: JSON.stringify({ trustProjectSkills: next }), + }); + state.trustProjectSkills = body.trustProjectSkills === 'all' ? 'all' : next; + syncChoiceButtons('[data-global-project-value]', 'globalProjectValue', state.trustProjectSkills); + } catch (err: any) { + window.alert(`${t('skills.failed')}: ${err?.message ?? err}`); + } finally { + setChoiceButtonsDisabled('[data-global-project-value]', false); + } + })); + + bodyEl.querySelectorAll('[data-global-delivery-value]').forEach(button => button.addEventListener('click', async () => { + const next = button.dataset.globalDeliveryValue === 'prompt' || button.dataset.globalDeliveryValue === 'native' + ? button.dataset.globalDeliveryValue + : 'auto'; + if (state.delivery === next) return; + try { + setChoiceButtonsDisabled('[data-global-delivery-value]', true); + const body = await jsonRequest('/api/skills/global', { + method: 'PUT', + body: JSON.stringify({ delivery: next }), + }); + state.delivery = body.delivery === 'prompt' || body.delivery === 'native' ? body.delivery : next; + syncChoiceButtons('[data-global-delivery-value]', 'globalDeliveryValue', state.delivery); + } catch (err: any) { + window.alert(`${t('skills.failed')}: ${err?.message ?? err}`); + } finally { + setChoiceButtonsDisabled('[data-global-delivery-value]', false); + } + })); + + bodyEl.querySelectorAll('[data-action="scroll-bots"]').forEach(button => button.addEventListener('click', () => { + const grid = bodyEl.querySelector('.skills-bot-grid'); + const card = grid?.querySelector('.skills-bot-card'); + if (!grid || !card) return; + const style = window.getComputedStyle(grid); + const gap = Number.parseFloat(style.columnGap || style.gap || '0') || 0; + const dir = button.dataset.dir === '-1' ? -1 : 1; + grid.scrollBy({ left: dir * (card.getBoundingClientRect().width + gap), behavior: 'smooth' }); + })); + + bodyEl.querySelectorAll('[data-action="page-installed-skills"]').forEach(button => button.addEventListener('click', () => { + const dir = button.dataset.dir === '-1' ? -1 : 1; + installedSkillsPage += dir; + clampInstalledSkillsPage(); + rerender(); + })); + + bodyEl.querySelectorAll('.skills-row').forEach(row => { + const name = row.dataset.skill ?? ''; + row.querySelector('[data-action="update-skill"]')?.addEventListener('click', async () => { + const button = row.querySelector('[data-action="update-skill"]'); + const panel = bodyEl.querySelector('.skills-install-panel'); + const statusEl = status(panel); + try { + if (button) button.disabled = true; + const body = await jsonRequest(`/api/skills/${encodeURIComponent(name)}/update`, { method: 'POST', body: '{}' }); + await waitForSkillJob(body.job as SkillJob, statusEl); + } catch (err: any) { + window.alert(`${t('skills.failed')}: ${err?.message ?? err}`); + } finally { + if (button) button.disabled = false; + } + }); + row.querySelector('[data-action="remove-skill"]')?.addEventListener('click', async () => { + if (!window.confirm(`${t('skills.remove')} ${name}?`)) return; + try { + await jsonRequest(`/api/skills/${encodeURIComponent(name)}`, { method: 'DELETE', body: '{}' }); + await refresh(); + } catch (err: any) { + if (err?.status === 409 && err?.body?.error === 'skill_in_use') { + const affected = Array.isArray(err.body.affectedBots) + ? err.body.affectedBots.map((bot: any) => { + const label = bot?.botName || bot?.larkAppId; + return label ? `${label}` : ''; + }).filter(Boolean) + : referencingBotLabels(name); + const refs = [ + affected.length ? `Bot: ${affected.join(', ')}` : '', + ].filter(Boolean).join('; ') || '-'; + if (!window.confirm(t('skills.removeInUse', { skill: name, refs }))) return; + try { + await jsonRequest(`/api/skills/${encodeURIComponent(name)}?force=1`, { method: 'DELETE', body: '{}' }); + await refresh(); + return; + } catch (forceErr: any) { + window.alert(`${t('skills.failed')}: ${forceErr?.message ?? forceErr}`); + return; + } + } + window.alert(`${t('skills.failed')}: ${err?.message ?? err}`); + } + }); + }); + + bodyEl.querySelectorAll('.skills-bot-card').forEach(card => { + const appId = card.dataset.appid ?? ''; + const bot = state.bots.find(b => b.larkAppId === appId); + if (!bot) return; + card.querySelector('[data-action="attach-skill"]')?.addEventListener('click', async () => { + const name = card.querySelector('[data-attach-picker]')?.value; + if (!name) return; + try { + const body = await jsonRequest(`/api/bots/${encodeURIComponent(appId)}/skills`, { + method: 'PUT', + body: JSON.stringify({ action: 'attach', name }), + }); + bot.skills = body.skills ?? null; + rerender(); + } catch (err: any) { + showStatus(status(card), `${t('skills.failed')}: ${err?.message ?? err}`, false); + } + }); + card.querySelectorAll('[data-action="detach-skill"]').forEach(btn => { + btn.addEventListener('click', async () => { + const name = btn.dataset.name; + if (!name) return; + try { + const body = await jsonRequest(`/api/bots/${encodeURIComponent(appId)}/skills`, { + method: 'PUT', + body: JSON.stringify({ action: 'detach', name }), + }); + bot.skills = body.skills ?? null; + rerender(); + } catch (err: any) { + showStatus(status(card), `${t('skills.failed')}: ${err?.message ?? err}`, false); + } + }); + }); + }); + } + + refreshBtn.onclick = () => { void refresh(); }; + await refresh(); +} diff --git a/src/dashboard/web/style.css b/src/dashboard/web/style.css index f28e936c..f40ec228 100644 --- a/src/dashboard/web/style.css +++ b/src/dashboard/web/style.css @@ -89,6 +89,7 @@ a { color: inherit; } height: 100vh; display: flex; flex-direction: column; + min-width: 0; padding: 18px 14px; background: var(--surface); border-right: 1px solid var(--border); @@ -131,6 +132,7 @@ a { color: inherit; } .sidebar-nav { display: grid; gap: 4px; + min-width: 0; margin-top: 24px; } @@ -3609,6 +3611,797 @@ button.contrast:hover { background: var(--danger-soft); border-color: var(--dang color: var(--muted); } +.skills-page-grid { + display: grid; + grid-template-columns: minmax(300px, 380px) minmax(0, 1fr); + gap: 18px; + align-items: start; + max-width: 1600px; +} + +.skills-side-rail { + position: sticky; + top: 18px; + z-index: 10; + display: grid; + gap: 14px; + min-width: 0; +} + +.skills-side-rail > .bd-card, +.skills-main-panel > .bd-card, +.skills-bot-card { + max-width: none; + margin-bottom: 0; +} + +.skills-defaults-panel, +.skills-install-panel { + padding: 16px; +} + +.skills-install-panel { + position: relative; +} + +.skills-install-title { + position: relative; + display: inline-flex; + align-items: center; + gap: 7px; + margin-bottom: 8px; +} + +.skills-install-title .bd-section-title { + margin: 0; +} + +.skills-help-tip { + position: relative; + display: inline-flex; +} + +.skills-help-button { + width: 20px; + height: 20px; + min-height: 20px; + padding: 0; + border-radius: 999px; + color: var(--muted); + font-size: 11px; + font-weight: 800; + line-height: 1; +} + +.skills-help-popover { + position: absolute; + left: 0; + top: calc(100% + 8px); + z-index: 50; + width: min(520px, calc(100vw - 48px)); + padding: 9px 10px; + border: 1px solid var(--border-soft); + border-radius: 8px; + background: var(--surface-raised); + box-shadow: var(--modal-shadow); + color: var(--fg); + font-size: 12px; + line-height: 1.45; + white-space: pre-line; + opacity: 0; + pointer-events: none; + transform: translate(0, -4px); + transition: opacity 140ms ease, transform 140ms ease; +} + +.skills-help-tip:hover .skills-help-popover, +.skills-help-tip:focus-within .skills-help-popover { + opacity: 1; + transform: translate(0, 0); +} + +.skills-install-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.skills-install-grid label:first-child, +.skills-install-field-wide, +.skills-install-note { + grid-column: 1 / -1; +} + +.skills-install-note { + display: grid; + gap: 4px; + margin: 0; +} + +.skills-install-note span { + display: block; +} + +.skills-install-note strong { + color: var(--fg); + font-weight: 700; +} + +.skills-install-grid label { + display: grid; + gap: 5px; + color: var(--muted); + font-size: 12px; +} + +.skills-install-grid input, +.skills-attach-row select { + width: 100%; + min-width: 0; +} + +.skills-main-panel, +.skills-bot-grid { + display: grid; + gap: 10px; +} + +.skills-main-panel { + gap: 18px; + min-width: 0; +} + +.skills-overview { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(320px, 0.72fr); + gap: 16px; + align-items: stretch; + padding: 16px 18px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + background: + linear-gradient(135deg, color-mix(in srgb, var(--accent) 10%, transparent), transparent 58%), + var(--surface); + box-shadow: var(--shadow); +} + +.skills-overview-copy { + display: grid; + align-content: center; + gap: 5px; + min-width: 0; +} + +.skills-overview-copy h2 { + margin: 0; + color: var(--fg); + font-size: 18px; + line-height: 1.25; +} + +.skills-overview-copy p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.55; +} + +.skills-metric-strip { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.skills-metric-strip span { + display: grid; + gap: 6px; + min-width: 0; + padding: 12px; + border: 1px solid var(--border-soft); + border-radius: 8px; + background: color-mix(in srgb, var(--surface-muted) 72%, transparent); +} + +.skills-metric-strip small { + color: var(--muted); + font-size: 11px; + font-weight: 700; +} + +.skills-metric-strip strong { + color: var(--fg); + font-size: 22px; + line-height: 1; + font-family: var(--mono); +} + +.skills-installed-panel { + padding: 18px; + min-width: 0; +} + +.skills-section-head { + display: grid; + gap: 4px; + margin-bottom: 12px; +} + +.skills-section-head-row { + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 12px; +} + +.skills-section-head h2 { + margin: 0; + font-size: 16px; +} + +.skills-section-head p { + margin: 0; + color: var(--muted); + font-size: 12px; +} + +.skills-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.skills-row { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 0; + min-height: 162px; + overflow: hidden; + border: 1px solid var(--border-soft); + border-radius: 8px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--surface-muted) 24%, transparent), transparent 46%), + var(--surface); +} + +.skills-row:first-child { border-top: 1px solid var(--border-soft); } +.skills-row-body { + min-width: 0; + padding: 14px 14px 12px; +} +.skills-row strong { + font-size: 13px; + line-height: 1.35; + overflow-wrap: anywhere; +} +.skills-row p { + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; + margin: 6px 0; + color: var(--muted); + font-size: 12.5px; + line-height: 1.45; + overflow-wrap: anywhere; +} +.skills-row small { color: var(--muted); font-family: var(--mono); font-size: 11px; overflow-wrap: anywhere; } + +.skills-card-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: auto; + border-top: 1px solid var(--border-soft); + background: color-mix(in srgb, var(--surface-muted) 55%, transparent); +} + +.skills-card-actions button { + min-height: 38px; + padding: 0 10px; + border: 0; + border-radius: 0; + background: transparent; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.skills-card-actions button + button { + border-left: 1px solid var(--border-soft); +} + +.skills-card-actions button:hover { + background: var(--surface-muted); + color: var(--fg); +} + +.skills-card-actions [data-action="remove-skill"]:hover { + color: var(--danger); +} + +.skills-installed-toolbar { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.skills-pager { + display: inline-flex; + align-items: center; + min-height: 26px; + overflow: hidden; + border: 1px solid var(--border-soft); + border-radius: 999px; + background: var(--surface-muted); + color: var(--muted); + font-size: 11px; + font-weight: 700; +} + +.skills-pager span { + min-width: 42px; + text-align: center; +} + +.skills-pager-button { + width: 26px; + height: 24px; + min-height: 24px; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: var(--muted); + font-size: 18px; + font-weight: 800; + line-height: 1; +} + +.skills-pager-button:hover:not(:disabled) { + background: var(--surface); + color: var(--accent-strong); +} + +.skills-bots-panel { + min-width: 0; + position: relative; +} + +.skills-bots-panel h2 { + margin: 0; + font-size: 16px; +} + +.skills-bot-grid { + display: flex; + gap: 14px; + align-items: stretch; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + padding: 2px 2px 12px; + scroll-padding-inline: 2px; + scroll-snap-type: x mandatory; + scrollbar-color: color-mix(in srgb, var(--accent) 45%, var(--border)) transparent; + scrollbar-width: thin; + -webkit-overflow-scrolling: touch; +} + +.skills-bot-grid::-webkit-scrollbar { + height: 8px; +} + +.skills-bot-grid::-webkit-scrollbar-track { + background: transparent; +} + +.skills-bot-grid::-webkit-scrollbar-thumb { + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 34%, var(--border)); +} + +.skills-bot-card { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + flex: 0 0 calc((100% - 28px) / 3); + align-content: stretch; + gap: 10px; + padding: 14px; + min-width: 0; + scroll-snap-align: start; + scroll-snap-stop: always; +} + +.skills-bot-card .bd-section { + display: flex; + flex-direction: column; + min-height: 0; + padding-top: 4px; +} + +.skills-bot-head { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px; + align-items: center; +} + +.skills-bot-head > div { + min-width: 0; + display: grid; + gap: 3px; +} + +.skills-bot-head code { + color: var(--muted); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.skills-bot-head strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.skills-count-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 24px; + padding: 3px 8px; + border: 1px solid var(--border-soft); + border-radius: 999px; + background: var(--surface-muted); + color: var(--muted); + font-size: 11px; + font-weight: 700; + white-space: nowrap; +} + +.skills-bot-rail-actions { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.skills-rail-button { + width: 26px; + height: 26px; + min-height: 26px; + padding: 0; + border-radius: 999px; + font-size: 18px; + font-weight: 800; + line-height: 1; +} + +.skills-chip-list { + display: grid; + gap: 2px; + margin-top: 4px; + max-height: 92px; + overflow-y: auto; + overscroll-behavior: contain; + padding-right: 3px; + scrollbar-color: color-mix(in srgb, var(--accent) 42%, var(--border)) transparent; + scrollbar-width: thin; +} + +.skills-chip-list::-webkit-scrollbar { + width: 6px; +} + +.skills-chip-list::-webkit-scrollbar-track { + background: transparent; +} + +.skills-chip-list::-webkit-scrollbar-thumb { + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 32%, var(--border)); +} + +.skills-priority-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-height: 28px; + padding: 2px 0; + border-bottom: 1px solid color-mix(in srgb, var(--border-soft) 68%, transparent); + font-size: 12px; + overflow-wrap: anywhere; +} + +.skills-priority-row:last-child { + border-bottom: 0; +} + +.skills-priority-name { + display: block; + min-width: 0; + color: var(--fg); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.skills-priority-name small { + margin-left: 6px; + color: var(--danger); + font-size: 11px; + font-weight: 700; + white-space: nowrap; +} + +.skills-priority-remove { + width: 24px; + height: 24px; + min-height: 24px; + padding: 0; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--muted); + font-size: 18px; + line-height: 1; +} + +.skills-priority-remove:hover { + background: var(--surface-muted); + color: var(--danger); +} + +.skills-priority-dangling .skills-priority-name { + color: var(--danger); +} + +.skills-policy-summary { + display: grid; + gap: 3px; + margin-top: 8px; + padding: 8px 10px; + border: 1px solid color-mix(in srgb, var(--accent) 18%, var(--border-soft)); + border-radius: 8px; + background: color-mix(in srgb, var(--accent) 6%, transparent); + color: var(--muted); + font-size: 11px; + line-height: 1.4; +} + +.skills-policy-summary strong { + color: var(--fg); + font-size: 11px; +} + +.skills-policy-summary span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.skills-attach-row, +.skills-policy-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: end; +} + +.skills-bot-card .skills-attach-row { + margin-top: auto; +} + +.skills-policy-controls { + grid-template-columns: 1fr; + align-items: stretch; +} + +.skills-control-block { + display: grid; + gap: 8px; + min-width: 0; +} + +.skills-defaults-panel .skills-control-block + .skills-control-block { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--border-soft); +} + +.skills-control-label { + color: var(--muted); + font-size: 12px; + font-weight: 600; +} + +.skills-choice-group { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.skills-choice-group-compact { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.skills-choice-group-compact .skills-choice { + gap: 7px; + min-height: 96px; + padding: 13px 14px; + align-content: start; +} + +.skills-choice-group-compact .skills-choice strong { + font-size: 13px; +} + +.skills-choice-group-compact .skills-choice small { + white-space: pre-line; + line-height: 1.42; +} + +.skills-project-group .skills-choice { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; +} + +.skills-project-group .skills-choice strong, +.skills-project-group .skills-choice small { + width: 100%; + text-align: left; +} + +.skills-defaults-panel .skills-choice-group:not(.skills-choice-group-compact) { + grid-template-columns: 1fr; +} + +.skills-choice { + display: grid; + gap: 3px; + width: 100%; + min-width: 0; + min-height: 62px; + padding: 9px 10px; + border: 1px solid var(--border-soft); + border-radius: 8px; + background: var(--surface); + color: var(--fg); + justify-items: start; + align-content: center; + text-align: left; + cursor: pointer; + transition: border-color 160ms ease, background 160ms ease, transform 160ms ease; +} + +.skills-choice:hover { + border-color: color-mix(in srgb, var(--accent) 35%, var(--border-soft)); + background: color-mix(in srgb, var(--accent) 6%, var(--surface)); +} + +.skills-choice:active { + transform: translateY(1px); +} + +.skills-choice strong { + overflow-wrap: anywhere; + font-size: 12px; + line-height: 1.3; +} + +.skills-choice small { + color: var(--muted); + font-size: 11px; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.skills-choice.selected { + border-color: color-mix(in srgb, var(--accent) 72%, var(--border-soft)); + background: color-mix(in srgb, var(--accent) 10%, var(--surface)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent); +} + +.skills-delivery-group .skills-choice { + grid-template-columns: minmax(96px, 0.32fr) minmax(0, 1fr); + column-gap: 14px; + min-height: 62px; + padding: 11px 14px; + align-content: start; +} + +.skills-delivery-group .skills-choice strong, +.skills-delivery-group .skills-choice small { + align-self: start; +} + +.skills-policy-controls [data-bot-status] { + grid-column: 1 / -1; +} + +@media (min-width: 1600px) { + .skills-list { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +@media (max-width: 1220px) { + .skills-bot-card { + flex-basis: calc((100% - 14px) / 2); + } +} + +@media (max-width: 980px) { + .skills-page-grid { + grid-template-columns: 1fr; + } + .skills-side-rail { + position: static; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .skills-overview { + grid-template-columns: 1fr; + } + .skills-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 620px) { + .skills-page-grid, + .skills-side-rail, + .skills-main-panel, + .skills-defaults-panel, + .skills-install-panel, + .skills-overview, + .skills-installed-panel { + width: 100%; + max-width: 100%; + } + .skills-side-rail, + .skills-install-grid, + .skills-section-head-row, + .skills-overview, + .skills-metric-strip, + .skills-row, + .skills-attach-row, + .skills-policy-controls, + .skills-choice-group, + .skills-choice-group-compact { + grid-template-columns: 1fr; + } + .skills-help-popover { + left: 16px; + right: 16px; + top: 54px; + width: auto; + } + .skills-install-title, + .skills-help-tip { + position: static; + } + .skills-list { + grid-template-columns: 1fr; + } + .skills-bot-card { + flex-basis: 100%; + } + .skills-bot-head { + grid-template-columns: auto minmax(0, 1fr); + } + .skills-bot-head .skills-count-pill { + grid-column: 1 / -1; + justify-self: start; + } + .skills-bot-rail-actions { + justify-content: flex-start; + } +} + /* toggle 开关行:checkbox 隐藏,switch 是视觉开关 */ .toggle-row { display: flex; diff --git a/src/global-config.ts b/src/global-config.ts index 5229c1d6..f0c88df4 100644 --- a/src/global-config.ts +++ b/src/global-config.ts @@ -51,6 +51,14 @@ export interface GlobalConfig { * so hosts behind a proxy must set this (or the env vars, which we read as a * fallback). Form: `http://host:port` or `http://user:pass@host:port`. */ httpProxy?: string; + /** Machine-wide user skill registry policy. Skill package storage itself lives under + * ~/.botmux/skills and is managed by services/skill-registry-store.ts. */ + skills?: GlobalSkillConfig; +} + +export interface GlobalSkillConfig { + trustProjectSkills?: 'off' | 'trusted' | 'all'; + delivery?: 'auto' | 'prompt' | 'native'; } export interface MaintenanceConfig { @@ -197,6 +205,19 @@ function readWorker(raw: unknown): WorkerConfig | undefined { return Object.keys(worker).length > 0 ? worker : undefined; } +function readGlobalSkills(raw: unknown): GlobalSkillConfig | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + const r = raw as Record; + const out: GlobalSkillConfig = {}; + if (r.trustProjectSkills === 'off' || r.trustProjectSkills === 'trusted' || r.trustProjectSkills === 'all') { + out.trustProjectSkills = r.trustProjectSkills; + } + if (r.delivery === 'auto' || r.delivery === 'prompt' || r.delivery === 'native') { + out.delivery = r.delivery; + } + return Object.keys(out).length > 0 ? out : undefined; +} + export function globalConfigPath(): string { return join(homedir(), '.botmux', 'config.json'); } @@ -252,6 +273,8 @@ export function readGlobalConfig(): GlobalConfig { const maintenance = readMaintenance(raw.maintenance); if (maintenance) out.maintenance = maintenance; if (typeof raw.httpProxy === 'string' && raw.httpProxy.trim()) out.httpProxy = raw.httpProxy.trim(); + const skills = readGlobalSkills(raw.skills); + if (skills) out.skills = skills; readCache = { path, value: out, at: Date.now() }; return out; } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index cba59720..5bcb465d 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -722,6 +722,7 @@ export const messages: Record = { // Worker-side submit / notify messages 'worker.submit_impossible': '⚠️ Your last message was NOT delivered to {cliName}: the current keybinding config can’t auto-submit from the terminal.\nReason: {reason}\nAdjust the Claude Code Chat keybinding, then resend.\nStart: {preview}', 'worker.submit_unconfirmed': '⚠️ Your last message was sent to {cliName} but submission couldn’t be confirmed (after retrying Enter and waiting {secs}s, no new entry showed up in {transcriptLabel}). It may be stuck in the input box — check the Web terminal and press Enter manually or resend.\nStart: {preview}', + 'worker.skill_delivery_failed': '⚠️ This bot’s Skill delivery config blocked the new session: {reason}\nSet skills.delivery to auto/prompt, or switch to a CLI that supports native skill delivery, then retry.', 'worker.coco_session_dir_gone': '⚠️ The current CoCo session directory was deleted (e2e cleanup or a manual rm). Content written to events.jsonl lands on a stale inode the bridge can’t read. Restart CoCo and run /adopt again.', // Restart / maintenance report (bot-0 DM to owner) diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index a667f02f..f8a6bca4 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -725,6 +725,7 @@ export const messages: Record = { // Worker-side submit / notify messages 'worker.submit_impossible': '⚠️ 刚才那条消息没有写入 {cliName},因为当前按键配置无法从终端自动提交。\n原因:{reason}\n请调整 Claude Code Chat keybinding 后重发。\n开头:{preview}', 'worker.submit_unconfirmed': '⚠️ 刚才那条消息发给 {cliName} 后没能确认提交(重试 Enter 后等了 {secs}s 仍未在{transcriptLabel}里看到新记录)。可能卡在输入框里——请去 Web 终端看一下,手动按 Enter 或重发。\n开头:{preview}', + 'worker.skill_delivery_failed': '⚠️ 当前 bot 的 Skill delivery 配置阻止了新会话启动:{reason}\n请把 skills.delivery 改为 auto/prompt,或改用支持 native skill delivery 的 CLI 后再重试。', 'worker.coco_session_dir_gone': '⚠️ 当前 CoCo 进程的会话目录已被删除(可能是 e2e 测试清理或手动 rm),写到 events.jsonl 的内容会落到一个失效 inode 上,桥接读不到。请重启 CoCo 后重新 /adopt。', // Restart / maintenance report (bot-0 DM to owner) diff --git a/src/services/bot-config-store.ts b/src/services/bot-config-store.ts index 09af2b80..8dbf393a 100644 --- a/src/services/bot-config-store.ts +++ b/src/services/bot-config-store.ts @@ -9,7 +9,7 @@ * (grants / quota)由既有 `/grant` 负责,不在此重复。 */ import type { BotConfig } from '../bot-registry.js'; -import { getBot } from '../bot-registry.js'; +import { getBot, readBotSkillPolicy } from '../bot-registry.js'; import { rmwBotEntry } from './config-store.js'; import { resolveAllowedUsersWithMap } from '../im/lark/client.js'; import { CLI_OPTIONS, resolveCliId } from '../setup/bot-config-editor.js'; @@ -26,7 +26,7 @@ import { logger } from '../utils/logger.js'; */ export type ConfigEffect = 'immediate' | 'next-session'; -export type ConfigFieldKind = 'string' | 'boolean' | 'enum' | 'cli' | 'dir' | 'allowedUsers'; +export type ConfigFieldKind = 'string' | 'boolean' | 'enum' | 'cli' | 'dir' | 'allowedUsers' | 'json'; export interface ConfigFieldSpec { /** 用户面命令里用的字段名(大小写不敏感匹配,见 {@link findConfigField})。 */ @@ -56,6 +56,7 @@ export const CONFIG_FIELDS: readonly ConfigFieldSpec[] = [ { key: 'brandLabel', configKey: 'brandLabel', kind: 'string', effect: 'immediate', clearable: true, hint: '卡片页脚品牌文案;unset 回默认 botmux 链接' }, { key: 'autoStartPrompt', configKey: 'autoStartOnGroupJoinPrompt', kind: 'string', effect: 'immediate', clearable: true, hint: '被拉进新群主动开工的首轮 prompt(配合 autoStartOnGroupJoin)' }, { key: 'allowedUsers', configKey: 'allowedUsers', kind: 'allowedUsers', effect: 'immediate', clearable: false, hint: '管理员名单(邮箱/on_/ou_,逗号或空格分隔);改后需加 确认' }, + { key: 'skills', configKey: 'skills', kind: 'json', effect: 'next-session', clearable: true, hint: 'bot 级 skill policy JSON;unset 回底层 CLI 默认行为' }, { key: 'disableStreamingCard', configKey: 'disableStreamingCard', kind: 'boolean', effect: 'immediate', clearable: false, hint: '关闭实时流式卡片 on|off' }, { key: 'writableTerminalLinkInCard', configKey: 'writableTerminalLinkInCard', kind: 'boolean', effect: 'immediate', clearable: false, hint: '卡片内嵌可写终端链接 on|off' }, { key: 'privateCard', configKey: 'privateCard', kind: 'boolean', effect: 'immediate', clearable: false, hint: '/card 发 owner-only 私有快照 on|off' }, @@ -92,6 +93,9 @@ function formatFieldValue(spec: ConfigFieldSpec, value: unknown): string { const arr = Array.isArray(value) ? value : []; return arr.length ? arr.join(', ') : '∅'; } + if (spec.kind === 'json') { + return value === undefined || value === null ? '∅' : JSON.stringify(value); + } if (value === undefined || value === null || value === '') return '∅'; return String(value); } @@ -140,7 +144,7 @@ export type ApplyFieldResult = export async function applyConfigField( larkAppId: string, spec: ConfigFieldSpec, - value: string | boolean | null, + value: unknown, ): Promise { if (spec.kind === 'allowedUsers') return { ok: false, reason: 'use_setBotAllowedUsers' }; let bot; @@ -154,8 +158,10 @@ export async function applyConfigField( // 与 parseBotConfigsFromText 一致:true 才写,false → 删 key(bots.json 保持干净)。 if (value === true) entry[spec.configKey] = true; else delete entry[spec.configKey]; + } else if (spec.kind === 'json') { + entry[spec.configKey] = value as any; } else { - entry[spec.configKey] = value; + entry[spec.configKey] = value as any; } return { write: true, result: null }; }); @@ -166,6 +172,8 @@ export async function applyConfigField( (bot.config as any)[spec.configKey] = undefined; } else if (spec.kind === 'boolean') { (bot.config as any)[spec.configKey] = value || undefined; + } else if (spec.kind === 'json') { + (bot.config as any)[spec.configKey] = value; } else { (bot.config as any)[spec.configKey] = value; } @@ -214,8 +222,8 @@ export async function setBotAllowedUsers( } export type CoerceResult = - | { ok: true; value: string | boolean } - | { ok: false; reason: 'invalid_bool' | 'invalid_enum' | 'invalid_cli' | 'invalid_dir' | 'empty' }; + | { ok: true; value: unknown } + | { ok: false; reason: 'invalid_bool' | 'invalid_enum' | 'invalid_cli' | 'invalid_dir' | 'invalid_json' | 'empty' }; /** * 把一个**原始**字段值(来自卡片下拉/输入或别处)按字段 kind 解析校验成可落盘的 @@ -245,6 +253,18 @@ export function coerceConfigValue(spec: ConfigFieldSpec, raw: unknown): CoerceRe try { if (statSync(expandHomePath(s)).isDirectory()) return { ok: true, value: s }; } catch { /* not a dir */ } return { ok: false, reason: 'invalid_dir' }; } + case 'json': { + try { + const parsed = JSON.parse(s); + if (spec.configKey === 'skills') { + const policy = readBotSkillPolicy(parsed); + return policy ? { ok: true, value: policy } : { ok: false, reason: 'invalid_json' }; + } + return { ok: true, value: parsed }; + } catch { + return { ok: false, reason: 'invalid_json' }; + } + } default: // 'string' return { ok: true, value: s }; } diff --git a/src/services/skill-registry-store.ts b/src/services/skill-registry-store.ts new file mode 100644 index 00000000..68dda1e9 --- /dev/null +++ b/src/services/skill-registry-store.ts @@ -0,0 +1,361 @@ +import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs'; +import { execFile, execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import { atomicWriteFileSync } from '../utils/atomic-write.js'; +import { withFileLock, withFileLockSync } from '../utils/file-lock.js'; +import { loadSkillPackage } from '../core/skills/package.js'; +import { skillRegistryPath, skillSourcesDir, skillStoreDir } from '../core/skills/registry-paths.js'; +import type { SkillPackage, SkillSource } from '../core/skills/types.js'; +import { assertNoGitUrlCredentials, assertSafeGitSkillPath, githubToGitUrl, redactGitUrlCredentials } from '../core/skills/sources.js'; + +const DEFAULT_GIT_TIMEOUT_MS = 60_000; +const execFileAsync = promisify(execFile); +const gitSourceLocks = new Map>(); + +export interface SkillRegistryFile { + schemaVersion: 1; + skills: Record; +} + +export function readSkillRegistry(): SkillRegistryFile { + const file = skillRegistryPath(); + if (!existsSync(file)) return { schemaVersion: 1, skills: {} }; + try { + const parsed = JSON.parse(readFileSync(file, 'utf-8')); + return { + schemaVersion: 1, + skills: parsed?.skills && typeof parsed.skills === 'object' ? parsed.skills : {}, + }; + } catch { + return { schemaVersion: 1, skills: {} }; + } +} + +function writeSkillRegistry(registry: SkillRegistryFile): void { + mkdirSync(dirname(skillRegistryPath()), { recursive: true }); + atomicWriteFileSync(skillRegistryPath(), JSON.stringify(registry, null, 2) + '\n', { mode: 0o600 }); +} + +export function installLocalSkill(dir: string, opts: { link: boolean }): SkillPackage { + const sourceDir = resolve(dir); + const provisional = loadSkillPackage(sourceDir, { + source: opts.link ? { type: 'local-link', path: sourceDir } : { type: 'local-copy', originalPath: sourceDir }, + }); + const rootDir = opts.link ? sourceDir : join(skillStoreDir(), provisional.name); + if (!opts.link) { + assertNoCopyOverlap(sourceDir, rootDir); + rmSync(rootDir, { recursive: true, force: true }); + mkdirSync(dirname(rootDir), { recursive: true }); + cpSync(sourceDir, rootDir, { recursive: true }); + } + const pkg = loadSkillPackage(rootDir, { + source: opts.link ? { type: 'local-link', path: sourceDir } : { type: 'local-copy', originalPath: sourceDir }, + id: provisional.id, + }); + const now = new Date().toISOString(); + const registry = readSkillRegistry(); + registry.skills[pkg.name] = { ...pkg, installedAt: now, updatedAt: now }; + writeSkillRegistry(registry); + return registry.skills[pkg.name]; +} + +function sourceId(url: string): string { + return createHash('sha256').update(url).digest('hex').slice(0, 16); +} + +function gitSourceLockTarget(url: string): string { + mkdirSync(skillSourcesDir(), { recursive: true }); + return join(skillSourcesDir(), sourceId(url)); +} + +function gitLockWaitMs(): number { + return Math.max(gitTimeoutMs() * 5, 60_000); +} + +function canonicalPath(path: string): string { + const resolved = resolve(path); + return existsSync(resolved) ? realpathSync(resolved) : resolved; +} + +function isSameOrChild(path: string, maybeParent: string): boolean { + return path === maybeParent || path.startsWith(maybeParent + '/'); +} + +function assertNoCopyOverlap(sourceDir: string, targetDir: string): void { + const source = canonicalPath(sourceDir); + const target = canonicalPath(targetDir); + if (isSameOrChild(source, target) || isSameOrChild(target, source)) { + throw new Error('local_skill_source_overlaps_store_target'); + } +} + +function assertPathWithin(parentDir: string, targetDir: string, error: string): void { + const parent = realpathSync(parentDir); + const target = realpathSync(targetDir); + if (target === parent) return; + const rel = relative(parent, target); + if (!rel || rel.startsWith('..') || isAbsolute(rel)) throw new Error(error); +} + +function gitSkillDir(sourceDir: string, path: string): string { + assertSafeGitSkillPath(path); + const skillDir = resolve(sourceDir, path); + assertPathWithin(sourceDir, skillDir, 'git_skill_path_outside_repo'); + return skillDir; +} + +async function withGitSourceLock(url: string, fn: () => Promise): Promise { + const key = sourceId(url); + const previous = gitSourceLocks.get(key) ?? Promise.resolve(); + const waitForPrevious = previous.catch(() => undefined); + let release!: () => void; + const current = new Promise(resolve => { release = resolve; }); + const tail = waitForPrevious.then(() => current); + gitSourceLocks.set(key, tail); + await waitForPrevious; + try { + return await withFileLock(gitSourceLockTarget(url), fn, { maxWaitMs: gitLockWaitMs() }); + } finally { + release(); + if (gitSourceLocks.get(key) === tail) gitSourceLocks.delete(key); + } +} + +function withGitSourceLockSync(url: string, fn: () => T): T { + return withFileLockSync(gitSourceLockTarget(url), fn, { maxWaitMs: gitLockWaitMs() }); +} + +function gitTimeoutMs(): number { + const raw = Number(process.env.BOTMUX_SKILL_GIT_TIMEOUT_MS); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_GIT_TIMEOUT_MS; +} + +function redactGitArg(arg: string): string { + return redactGitUrlCredentials(arg); +} + +function formatGitCommand(args: string[]): string { + return `git ${args.map(redactGitArg).join(' ')}`; +} + +function isGitNotFoundError(err: any): boolean { + return err?.code === 'ENOENT'; +} + +function formatGitFailure(args: string[], err: any): Error { + if (isGitNotFoundError(err)) return new Error('git_not_found'); + const stderr = Buffer.isBuffer(err?.stderr) ? err.stderr.toString('utf-8').trim() : String(err?.stderr ?? '').trim(); + const reason = [ + stderr ? redactGitUrlCredentials(stderr) : '', + err?.signal ? `signal ${err.signal}` : '', + err?.status !== undefined ? `status ${err.status}` : '', + err?.code ? `code ${err.code}` : '', + ].filter(Boolean).join('; ') || (err?.message ? redactGitUrlCredentials(err.message) : String(err)); + return new Error(`skill_git_command_failed: ${formatGitCommand(args)}: ${reason}`); +} + +function git(args: string[], cwd?: string): string { + try { + return execFileSync('git', args, { + cwd, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: gitTimeoutMs(), + }).trim(); + } catch (err: any) { + throw formatGitFailure(args, err); + } +} + +async function gitAsync(args: string[], cwd?: string): Promise { + try { + const result = await execFileAsync('git', args, { + cwd, + encoding: 'utf-8', + timeout: gitTimeoutMs(), + }); + return String(result.stdout ?? '').trim(); + } catch (err: any) { + throw formatGitFailure(args, err); + } +} + +function ensureGitSource(url: string): string { + assertNoGitUrlCredentials(url); + const dir = join(skillSourcesDir(), sourceId(url)); + mkdirSync(skillSourcesDir(), { recursive: true }); + if (existsSync(join(dir, '.git'))) { + git(['fetch', '--tags', '--prune'], dir); + } else { + git(['clone', url, dir]); + } + return dir; +} + +async function ensureGitSourceAsync(url: string): Promise { + assertNoGitUrlCredentials(url); + const dir = join(skillSourcesDir(), sourceId(url)); + mkdirSync(skillSourcesDir(), { recursive: true }); + if (existsSync(join(dir, '.git'))) { + await gitAsync(['fetch', '--tags', '--prune'], dir); + } else { + await gitAsync(['clone', url, dir]); + } + return dir; +} + +export function installGitSkill(opts: { + url: string; + path: string; + ref?: string; + sourceOverride?: SkillSource; +}): SkillPackage { + return withGitSourceLockSync(opts.url, () => installGitSkillLocked(opts)); +} + +function installGitSkillLocked(opts: { + url: string; + path: string; + ref?: string; + sourceOverride?: SkillSource; +}): SkillPackage { + const sourceDir = ensureGitSource(opts.url); + const ref = opts.ref ?? 'HEAD'; + if (ref === 'HEAD') { + git(['fetch', 'origin', 'HEAD'], sourceDir); + git(['checkout', 'FETCH_HEAD'], sourceDir); + } else { + git(['checkout', ref], sourceDir); + } + const commit = git(['rev-parse', 'HEAD'], sourceDir); + const source: SkillSource = opts.sourceOverride + ? opts.sourceOverride.type === 'git' || opts.sourceOverride.type === 'github' + ? { ...opts.sourceOverride, commit } + : opts.sourceOverride + : { type: 'git', url: opts.url, path: opts.path, ref, commit }; + const skillDir = gitSkillDir(sourceDir, opts.path); + const provisional = loadSkillPackage(skillDir, { source }); + const rootDir = join(skillStoreDir(), provisional.name); + rmSync(rootDir, { recursive: true, force: true }); + mkdirSync(dirname(rootDir), { recursive: true }); + cpSync(skillDir, rootDir, { recursive: true }); + const pkg = loadSkillPackage(rootDir, { source, id: provisional.id }); + const now = new Date().toISOString(); + const registry = readSkillRegistry(); + registry.skills[pkg.name] = { ...pkg, installedAt: now, updatedAt: now }; + writeSkillRegistry(registry); + return registry.skills[pkg.name]; +} + +export async function installGitSkillAsync(opts: { + url: string; + path: string; + ref?: string; + sourceOverride?: SkillSource; +}): Promise { + return withGitSourceLock(opts.url, () => installGitSkillAsyncLocked(opts)); +} + +async function installGitSkillAsyncLocked(opts: { + url: string; + path: string; + ref?: string; + sourceOverride?: SkillSource; +}): Promise { + const sourceDir = await ensureGitSourceAsync(opts.url); + const ref = opts.ref ?? 'HEAD'; + if (ref === 'HEAD') { + await gitAsync(['fetch', 'origin', 'HEAD'], sourceDir); + await gitAsync(['checkout', 'FETCH_HEAD'], sourceDir); + } else { + await gitAsync(['checkout', ref], sourceDir); + } + const commit = await gitAsync(['rev-parse', 'HEAD'], sourceDir); + const source: SkillSource = opts.sourceOverride + ? opts.sourceOverride.type === 'git' || opts.sourceOverride.type === 'github' + ? { ...opts.sourceOverride, commit } + : opts.sourceOverride + : { type: 'git', url: opts.url, path: opts.path, ref, commit }; + const skillDir = gitSkillDir(sourceDir, opts.path); + const provisional = loadSkillPackage(skillDir, { source }); + const rootDir = join(skillStoreDir(), provisional.name); + rmSync(rootDir, { recursive: true, force: true }); + mkdirSync(dirname(rootDir), { recursive: true }); + cpSync(skillDir, rootDir, { recursive: true }); + const pkg = loadSkillPackage(rootDir, { source, id: provisional.id }); + const now = new Date().toISOString(); + const registry = readSkillRegistry(); + registry.skills[pkg.name] = { ...pkg, installedAt: now, updatedAt: now }; + writeSkillRegistry(registry); + return registry.skills[pkg.name]; +} + +export function removeInstalledSkill(name: string): { ok: true } | { ok: false; reason: string } { + const registry = readSkillRegistry(); + const pkg = registry.skills[name]; + if (!pkg) return { ok: false, reason: 'skill_not_installed' }; + delete registry.skills[name]; + writeSkillRegistry(registry); + if (pkg.source.type !== 'local-link' && isStoreManagedRoot(pkg.rootDir)) { + rmSync(pkg.rootDir, { recursive: true, force: true }); + } + return { ok: true }; +} + +function isStoreManagedRoot(rootDir: string): boolean { + const storePath = resolve(skillStoreDir()); + const targetPath = resolve(rootDir); + const store = existsSync(storePath) ? realpathSync(storePath) : storePath; + const target = existsSync(targetPath) ? realpathSync(targetPath) : targetPath; + if (target === store) return false; + const rel = relative(store, target); + return !!rel && !rel.startsWith('..') && !isAbsolute(rel); +} + +export function updateInstalledSkill(name: string): { ok: true; skill: SkillPackage } | { ok: false; reason: string } { + const current = readSkillRegistry().skills[name]; + if (!current) return { ok: false, reason: 'skill_not_installed' }; + const source = current.source; + if (source.type === 'local-copy') return { ok: true, skill: installLocalSkill(source.originalPath, { link: false }) }; + if (source.type === 'local-link') return { ok: true, skill: installLocalSkill(source.path, { link: true }) }; + if (source.type === 'git') { + return { ok: true, skill: installGitSkill({ url: source.url, path: source.path, ref: source.ref }) }; + } + if (source.type === 'github') { + return { + ok: true, + skill: installGitSkill({ + url: githubToGitUrl(source.owner, source.repo), + path: source.path, + ref: source.ref, + sourceOverride: source, + }), + }; + } + return { ok: false, reason: `unsupported_source:${source.type}` }; +} + +export async function updateInstalledSkillAsync(name: string): Promise<{ ok: true; skill: SkillPackage } | { ok: false; reason: string }> { + const current = readSkillRegistry().skills[name]; + if (!current) return { ok: false, reason: 'skill_not_installed' }; + const source = current.source; + if (source.type === 'local-copy') return { ok: true, skill: installLocalSkill(source.originalPath, { link: false }) }; + if (source.type === 'local-link') return { ok: true, skill: installLocalSkill(source.path, { link: true }) }; + if (source.type === 'git') { + return { ok: true, skill: await installGitSkillAsync({ url: source.url, path: source.path, ref: source.ref }) }; + } + if (source.type === 'github') { + return { + ok: true, + skill: await installGitSkillAsync({ + url: githubToGitUrl(source.owner, source.repo), + path: source.path, + ref: source.ref, + sourceOverride: source, + }), + }; + } + return { ok: false, reason: `unsupported_source:${source.type}` }; +} diff --git a/src/skills/installer.ts b/src/skills/installer.ts index c87ac677..2d4f15d6 100644 --- a/src/skills/installer.ts +++ b/src/skills/installer.ts @@ -5,6 +5,10 @@ import { homedir } from 'node:os'; import { logger } from '../utils/logger.js'; import { BUILTIN_SKILLS, RETIRED_SKILL_NAMES, ASK_SKILL, ASK_SKILL_NAME } from './definitions.js'; +// This module only manages botmux-owned bridge/ask skills. User-defined skills +// live in src/core/skills/* and services/skill-registry-store.ts so their +// lifecycle stays independent of any specific CLI's global skill directory. + function expandHome(p: string): string { return p.startsWith('~') ? join(homedir(), p.slice(1)) : p; } diff --git a/src/types.ts b/src/types.ts index 5aaf271a..609223f0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -251,7 +251,7 @@ export type TermActionKey = /** Messages sent from Daemon to Worker */ export type DaemonToWorker = - | { type: 'init'; sessionId: string; chatId: string; rootMessageId: string; workingDir: string; cliId: string; cliPathOverride?: string; wrapperCli?: string; model?: string; disableCliBypass?: boolean; sandbox?: boolean; sandboxHidePaths?: string[]; backendType: BackendType; prompt: string; resume?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean } + | { type: 'init'; sessionId: string; chatId: string; rootMessageId: string; workingDir: string; cliId: string; cliPathOverride?: string; wrapperCli?: string; model?: string; disableCliBypass?: boolean; sandbox?: boolean; sandboxHidePaths?: string[]; backendType: BackendType; prompt: string; resume?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean } | { type: 'message'; content: string; turnId?: string } /** Literal slash-command passthrough. `followUpContent` rides along so the * worker enqueues it strictly AFTER the slash command's Enter — two separate diff --git a/src/utils/file-lock.ts b/src/utils/file-lock.ts index 4722820e..cd083f35 100644 --- a/src/utils/file-lock.ts +++ b/src/utils/file-lock.ts @@ -19,7 +19,16 @@ * out. (We could allow reentrancy via PID-equal check, but our callers * don't need it and the equality check would re-open the stale-break race.) */ -import { promises as fsp } from 'node:fs'; +import { + closeSync, + openSync, + promises as fsp, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; import { randomBytes } from 'node:crypto'; import { logger } from './logger.js'; @@ -36,6 +45,16 @@ async function isPidAlive(pid: number): Promise { try { process.kill(pid, 0); return true; } catch { return false; } } +function isPidAliveSync(pid: number): boolean { + if (!pid) return false; + if (pid === process.pid) return true; + try { process.kill(pid, 0); return true; } catch { return false; } +} + +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + export interface FileLockOptions { /** Max time to wait for the lock before throwing (default MAX_WAIT_MS). */ maxWaitMs?: number; @@ -111,3 +130,67 @@ export async function withFileLock( } } } + +export function withFileLockSync( + targetPath: string, + fn: () => T, + opts: FileLockOptions = {}, +): T { + const maxWaitMs = opts.maxWaitMs ?? MAX_WAIT_MS; + const minStaleAgeMs = opts.minStaleAgeMs ?? MIN_STALE_AGE_MS; + const lockPath = targetPath + '.lock'; + const start = Date.now(); + while (true) { + let fd: number | null = null; + try { + fd = openSync(lockPath, 'wx'); + writeFileSync(fd, String(process.pid)); + closeSync(fd); + fd = null; + try { + return fn(); + } finally { + try { unlinkSync(lockPath); } catch { /* already gone, tolerate */ } + } + } catch (e: any) { + if (fd !== null) { + try { closeSync(fd); } catch { /* tolerate */ } + } + if (e.code !== 'EEXIST') throw e; + + let holder = 0; + let lockAgeMs = Infinity; + try { + holder = parseInt(readFileSync(lockPath, 'utf-8'), 10) || 0; + lockAgeMs = Date.now() - statSync(lockPath).mtimeMs; + } catch (re: any) { + if (re.code === 'ENOENT') continue; + throw re; + } + + const breakable = holder + && lockAgeMs >= minStaleAgeMs + && !isPidAliveSync(holder); + if (breakable) { + const stalePath = `${lockPath}.stale.${process.pid}.${randomBytes(4).toString('hex')}`; + try { + renameSync(lockPath, stalePath); + logger.warn(`[file-lock] broke stale lock at ${lockPath} (dead pid ${holder}, age ${lockAgeMs}ms)`); + try { unlinkSync(stalePath); } catch { /* tolerate */ } + continue; + } catch (renameErr: any) { + if (renameErr.code === 'ENOENT') continue; + throw renameErr; + } + } + + if (Date.now() - start > maxWaitMs) { + throw new Error( + `file-lock timeout waiting for ${lockPath} ` + + `(held by pid ${holder || '?'}, age ${Math.round(lockAgeMs)}ms)`, + ); + } + sleepSync(RETRY_BASE_MS + Math.random() * RETRY_BASE_MS); + } + } +} diff --git a/src/worker.ts b/src/worker.ts index 35629e7b..cb239d09 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -3580,6 +3580,7 @@ function spawnCli(cfg: Extract): void { locale: cfg.locale, model: ttadkGateway ? undefined : cfg.model, disableCliBypass: cfg.disableCliBypass === true, + skillPluginDir: cfg.skillPluginDir, }); // Extra args from env (CLI_DISABLE_DEFAULT_ARGS is removed — adapters own their defaults) @@ -3746,6 +3747,7 @@ function spawnCli(cfg: Extract): void { hidePaths: cfg.sandboxHidePaths ?? [], authPaths: cliAdapter.authPaths, extraExecPaths: cliAdapter.sandboxExtraExecPaths?.(), + readonlyRoots: cfg.skillReadonlyRoots ?? [], }); if (sbx) { spawnBin = sbx.bin; diff --git a/test/bot-config-store.test.ts b/test/bot-config-store.test.ts index e062ff54..c67cad7b 100644 --- a/test/bot-config-store.test.ts +++ b/test/bot-config-store.test.ts @@ -79,6 +79,7 @@ describe('bot-config store', () => { expect(keys).toContain('allowedUsers'); expect(keys).toContain('model'); expect(keys).not.toContain('repoPickerMode'); + expect(keys).toContain('skills'); }); it('parseBooleanValue accepts on/off variants and rejects junk', async () => { @@ -111,6 +112,57 @@ describe('bot-config store', () => { expect(registry.getBot('app_default').config.model).toBeUndefined(); }); + it('parses bot skill policy while leaving omitted policy undefined', async () => { + const { registry } = await freshModules(); + const [plain, skilled, advancedOnly] = registry.parseBotConfigsFromText(JSON.stringify([ + { larkAppId: 'plain', larkAppSecret: 's', cliId: 'codex' }, + { + larkAppId: 'skilled', + larkAppSecret: 's', + cliId: 'codex', + skills: { + profiles: ['frontend'], + include: ['skill:deploy-runbook'], + exclude: ['skill:old-release'], + projectSkills: 'trusted', + mode: 'priority', + delivery: 'auto', + }, + }, + { + larkAppId: 'advanced-only', + larkAppSecret: 's', + cliId: 'codex', + skills: { + delivery: 'prompt', + projectSkills: 'all', + }, + }, + ])); + + expect(plain.skills).toBeUndefined(); + expect(skilled.skills).toEqual({ include: ['skill:deploy-runbook'] }); + expect(advancedOnly.skills).toBeUndefined(); + }); + + it('sets and unsets JSON skills policy through /config store', async () => { + const { registry, store } = await loaded(); + const spec = store.findConfigField('skills')!; + const coerced = store.coerceConfigValue(spec, '{"include":["skill:deploy-runbook"],"delivery":"prompt"}'); + expect(coerced).toEqual({ ok: true, value: { include: ['skill:deploy-runbook'] } }); + if (!coerced.ok) throw new Error('coerce failed'); + + const r1 = await store.applyConfigField('app_default', spec, coerced.value); + expect(r1.ok).toBe(true); + expect(readConfig().skills).toEqual({ include: ['skill:deploy-runbook'] }); + expect(registry.getBot('app_default').config.skills).toEqual({ include: ['skill:deploy-runbook'] }); + + const r2 = await store.applyConfigField('app_default', spec, null); + expect(r2.ok).toBe(true); + expect(readConfig().skills).toBeUndefined(); + expect(registry.getBot('app_default').config.skills).toBeUndefined(); + }); + it('boolean field writes true / deletes key on false (keeps bots.json tidy)', async () => { const { registry, store } = await loaded(); const spec = store.findConfigField('disableStreamingCard')!; diff --git a/test/command-handler.test.ts b/test/command-handler.test.ts index 86d6eb6d..677adbf5 100644 --- a/test/command-handler.test.ts +++ b/test/command-handler.test.ts @@ -77,6 +77,14 @@ vi.mock('../src/bot-registry.js', () => ({ workingDirs: ['~/projects'], }, })), + readBotSkillPolicy: vi.fn((raw: unknown) => { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + const r = raw as Record; + const out: Record = {}; + if (Array.isArray(r.include)) out.include = r.include.filter((item) => typeof item === 'string' && item.startsWith('skill:')); + return Object.keys(out).length ? out : undefined; + }), + getLoadedConfigPath: vi.fn(() => process.env.BOTS_CONFIG), // Production runs ONE daemon per bot, so getAllBots() sees only this process's // own bot. Default to the Claude process; the split-brain test overrides this // to prove the /group election does NOT depend on getAllBots(). @@ -363,7 +371,9 @@ import { createGroupWithBots } from '../src/services/group-creator.js'; import { getAllBots, getBot } from '../src/bot-registry.js'; import { generateAuthUrl, getTokenStatus } from '../src/utils/user-token.js'; import { bindOncall } from '../src/services/oncall-store.js'; -import { existsSync, statSync, readFileSync } from 'node:fs'; +import { existsSync, statSync, readFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { scanMultipleProjects } from '../src/services/project-scanner.js'; import { repoPickerScanOptions } from '../src/global-config.js'; import { createRepoWorktree } from '../src/services/git-worktree.js'; @@ -476,7 +486,7 @@ function mockCodexAppBot(): void { describe('DAEMON_COMMANDS set', () => { it('should contain all expected commands', () => { - const expected = ['/close', '/restart', '/status', '/help', '/cd', '/repo', '/schedule', '/role', '/botconfig', '/pair', '/login', '/adopt', '/detach', '/disconnect', '/oncall', '/group', '/g', '/relay', '/card', '/term', '/list-slash-command', '/slash', '/land']; + const expected = ['/close', '/restart', '/status', '/help', '/cd', '/repo', '/schedule', '/role', '/botconfig', '/skills', '/pair', '/login', '/adopt', '/detach', '/disconnect', '/oncall', '/group', '/g', '/relay', '/card', '/term', '/list-slash-command', '/slash', '/land']; for (const cmd of expected) { expect(DAEMON_COMMANDS.has(cmd), `Expected DAEMON_COMMANDS to contain ${cmd}`).toBe(true); } @@ -497,9 +507,9 @@ describe('DAEMON_COMMANDS set', () => { }); it('should have the correct size', () => { - // 24 = 21 original + /land (sandbox-landing) + /term (operable-terminal slash) - // + /subscribe-lark-doc (Feishu doc comment entry). - expect(DAEMON_COMMANDS.size).toBe(24); + // 25 = 21 original + /land (sandbox-landing) + /term (operable-terminal slash) + // + /subscribe-lark-doc (Feishu doc comment entry) + /skills. + expect(DAEMON_COMMANDS.size).toBe(25); }); it('contains the /list-slash-command lister and its /slash alias', () => { @@ -561,6 +571,7 @@ describe('SESSIONLESS_DAEMON_COMMANDS set', () => { it('contains /group and its /g alias', () => { expect(SESSIONLESS_DAEMON_COMMANDS.has('/group')).toBe(true); expect(SESSIONLESS_DAEMON_COMMANDS.has('/g')).toBe(true); + expect(SESSIONLESS_DAEMON_COMMANDS.has('/skills')).toBe(true); }); it('is a subset of DAEMON_COMMANDS (they are still daemon-handled)', () => { @@ -579,6 +590,52 @@ describe('SESSIONLESS_DAEMON_COMMANDS set', () => { }); }); +describe('/botconfig skills JSON text command', () => { + it('persists skills as a parsed policy object, not a raw JSON string', async () => { + const dir = mkdtempSync(join(tmpdir(), 'botmux-botconfig-skills-')); + const configPath = join(dir, 'bots.json'); + process.env.BOTS_CONFIG = configPath; + writeFileSync(configPath, JSON.stringify([{ + larkAppId: 'app-1', + larkAppSecret: 'secret-1', + cliId: 'codex', + allowedUsers: ['ou_sender'], + }])); + const bot = { + botName: 'Codex', + config: { + larkAppId: 'app-1', + larkAppSecret: 'secret-1', + cliId: 'codex' as const, + allowedUsers: ['ou_sender'], + workingDir: '~/projects', + workingDirs: ['~/projects'], + }, + resolvedAllowedUsers: ['ou_sender'], + }; + vi.mocked(getBot).mockReturnValue(bot as any); + + try { + await handleCommand( + '/botconfig', + ROOT_ID, + makeLarkMessage('/botconfig set skills {"include":["skill:deploy-runbook"],"delivery":"prompt","projectSkills":"all"}', { senderId: 'ou_sender' }), + makeDeps(), + 'app-1', + ); + + const stored = JSON.parse(readFileSync(configPath, 'utf-8'))[0]; + expect(stored.skills).toEqual({ include: ['skill:deploy-runbook'] }); + expect(typeof stored.skills).toBe('object'); + expect(bot.config.skills).toEqual({ include: ['skill:deploy-runbook'] }); + } finally { + delete process.env.BOTS_CONFIG; + rmSync(dir, { recursive: true, force: true }); + vi.mocked(getBot).mockImplementation(defaultGetBot as any); + } + }); +}); + describe('PASSTHROUGH_COMMANDS set', () => { it('should contain expected slash commands forwarded to CLI', () => { for (const cmd of ['/compact', '/model', '/clear', '/plugin', '/usage', '/context', '/cost', '/mcp', '/diff', '/btw']) { diff --git a/test/dashboard-auth.test.ts b/test/dashboard-auth.test.ts index 3acc42f8..81cf898c 100644 --- a/test/dashboard-auth.test.ts +++ b/test/dashboard-auth.test.ts @@ -447,6 +447,7 @@ describe('decideDashboardAuth — publicReadOnly mode', () => { // (role/persona content, per-bot oncall config, CLI option metadata). '/api/roles/cli_app/oc_chat', '/api/bots', + '/api/skills', '/api/cli-options', // Mints a token-bearing writable terminal URL — never public, even in // publicReadOnly (the daemon IPC behind it is also loopback-HMAC gated). diff --git a/test/dashboard-ipc.test.ts b/test/dashboard-ipc.test.ts index 81b5e8af..955a7ee0 100644 --- a/test/dashboard-ipc.test.ts +++ b/test/dashboard-ipc.test.ts @@ -6,6 +6,7 @@ import { dashboardEventBus } from '../src/core/dashboard-events.js'; import * as groupsStore from '../src/services/groups-store.js'; import * as oncallStore from '../src/services/oncall-store.js'; import * as workerPool from '../src/core/worker-pool.js'; +import { registerBot } from '../src/bot-registry.js'; // Loopback-HMAC the write-link routes require. Inject a known secret per test // (setIpcAuthSecret) and sign with it, so the suite doesn't depend on a real @@ -285,6 +286,31 @@ describe('POST /api/locale/reload', () => { }); }); +describe('PUT /api/bot-skills', () => { + it('rejects invalid non-null policy instead of clearing skills', async () => { + const appId = 'test-skill-policy-app'; + setLarkAppId(appId); + registerBot({ + larkAppId: appId, + larkAppSecret: 'secret', + cliId: 'codex', + workingDir: process.cwd(), + workingDirs: [process.cwd()], + skills: { include: ['skill:deploy'] }, + } as any); + handle = await startIpcServer({ port: 0, host: '127.0.0.1' }); + + const res = await fetch(`http://127.0.0.1:${handle.port}/api/bot-skills`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'set', policy: { include: [123] } }), + }); + + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ ok: false, error: 'invalid_policy' }); + }); +}); + describe('GET /api/groups (Phase B)', () => { it('returns 503 when larkAppId not set', async () => { setLarkAppId(''); diff --git a/test/dashboard-skill-install-request.test.ts b/test/dashboard-skill-install-request.test.ts new file mode 100644 index 00000000..60ed2830 --- /dev/null +++ b/test/dashboard-skill-install-request.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { parseDashboardSkillInstallRequest, shouldAutoLinkLocalSkillPath } from '../src/dashboard/skill-install-request.js'; + +describe('dashboard skill install request parsing', () => { + it('rejects lightweight install errors before starting a job', () => { + expect(() => parseDashboardSkillInstallRequest({ source: '' })).toThrow(/source_required/); + expect(() => parseDashboardSkillInstallRequest({ source: 'git+https://github.com/acme/skills.git' })).toThrow(/path_required/); + expect(() => parseDashboardSkillInstallRequest({ + source: 'git+https://token@example.com/acme/skills.git', + path: 'skills/deploy', + })).toThrow(/git_url_credentials_not_allowed/); + expect(() => parseDashboardSkillInstallRequest({ + source: 'git+https://github.com/acme/skills.git', + path: '../deploy', + })).toThrow(/invalid_git_skill_path/); + }); + + it('parses GitHub shorthand paths and explicit overrides', () => { + expect(parseDashboardSkillInstallRequest({ source: 'github:acme/skills/skills/deploy' })).toMatchObject({ + kind: 'github', + owner: 'acme', + repo: 'skills', + path: 'skills/deploy', + }); + expect(parseDashboardSkillInstallRequest({ + source: 'github:acme/skills/skills/deploy', + path: 'skills/runbook', + ref: 'main', + })).toMatchObject({ + kind: 'github', + path: 'skills/runbook', + ref: 'main', + }); + }); + + it('parses GitHub browser URLs and uses their ref/path by default', () => { + expect(parseDashboardSkillInstallRequest({ + source: 'https://github.com/acme/skills/tree/main/skills/deploy', + })).toMatchObject({ + kind: 'github', + owner: 'acme', + repo: 'skills', + path: 'skills/deploy', + ref: 'main', + }); + expect(parseDashboardSkillInstallRequest({ + source: 'https://github.com/acme/skills/tree/main', + path: 'skills/runbook', + })).toMatchObject({ + kind: 'github', + path: 'skills/runbook', + ref: 'main', + }); + }); + + it('auto-links native local skill library paths without a dashboard toggle', () => { + expect(shouldAutoLinkLocalSkillPath('/Users/me/.codex/skills/deploy')).toBe(true); + expect(shouldAutoLinkLocalSkillPath('/Users/me/.claude/skills/deploy')).toBe(true); + expect(shouldAutoLinkLocalSkillPath('/repo/.agents/skills/deploy')).toBe(true); + expect(shouldAutoLinkLocalSkillPath('/repo/custom-skills/deploy')).toBe(false); + expect(parseDashboardSkillInstallRequest({ source: '/Users/me/.codex/skills/deploy' })).toMatchObject({ + kind: 'local', + link: true, + }); + expect(parseDashboardSkillInstallRequest({ source: '/repo/custom-skills/deploy' })).toMatchObject({ + kind: 'local', + link: false, + }); + }); +}); diff --git a/test/file-lock.test.ts b/test/file-lock.test.ts index 9ec159e7..46a27bbf 100644 --- a/test/file-lock.test.ts +++ b/test/file-lock.test.ts @@ -14,7 +14,7 @@ import { mkdtempSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it, expect, beforeEach } from 'vitest'; -import { withFileLock } from '../src/utils/file-lock.js'; +import { withFileLock, withFileLockSync } from '../src/utils/file-lock.js'; describe('withFileLock', () => { let target: string; @@ -31,6 +31,12 @@ describe('withFileLock', () => { expect(existsSync(target + '.lock')).toBe(false); }); + it('runs sync fn and releases the lock', () => { + const result = withFileLockSync(target, () => 'ok-sync'); + expect(result).toBe('ok-sync'); + expect(existsSync(target + '.lock')).toBe(false); + }); + it('serializes concurrent same-process callers (no interleave inside fn)', async () => { let inFlight = 0; let maxInFlight = 0; diff --git a/test/global-config.test.ts b/test/global-config.test.ts index fb43de70..a0b11069 100644 --- a/test/global-config.test.ts +++ b/test/global-config.test.ts @@ -50,6 +50,20 @@ describe('global dashboard config', () => { expect(readGlobalConfig().repoPickerMode).toBeUndefined(); }); + it('reads global skill project trust policy and delivery default', () => { + writeFileSync(globalConfigPath(), JSON.stringify({ + skills: { + trustProjectSkills: 'trusted', + delivery: 'prompt', + }, + })); + + expect(readGlobalConfig().skills).toEqual({ + trustProjectSkills: 'trusted', + delivery: 'prompt', + }); + }); + it('readGlobalConfig sees fresh values immediately after a merge (cache invalidation)', () => { writeFileSync(globalConfigPath(), JSON.stringify({ dashboard: { publicReadOnly: true } })); expect(readGlobalConfig().dashboard?.publicReadOnly).toBe(true); // primes the TTL cache diff --git a/test/sandbox.test.ts b/test/sandbox.test.ts index 4a419102..49bcf2fa 100644 --- a/test/sandbox.test.ts +++ b/test/sandbox.test.ts @@ -90,6 +90,17 @@ describe('buildSandboxArgs (overlay model)', () => { expect(outboxIdx).toBeGreaterThan(maskIdx); // outbox bind comes after the mask }); + it('binds selected skill runtime roots read-only before the outbox', () => { + const a = buildSandboxArgs(plan({ + readonlyRoots: ['/data/runtime-skills/s1/claude-plugin'], + })); + const rootIdx = tripleIdx(a, '--ro-bind', '/data/runtime-skills/s1/claude-plugin', '/data/runtime-skills/s1/claude-plugin'); + const outboxIdx = tripleIdx(a, '--bind', '/data/sandboxes/s1/outbox', '/data/sandboxes/s1/outbox'); + + expect(rootIdx).toBeGreaterThanOrEqual(0); + expect(outboxIdx).toBeGreaterThan(rootIdx); + }); + it('no clone/scrub artefacts: never binds a per-session clone "work" dir', () => { const a = buildSandboxArgs(plan()); // The old model bound a `git clone` "work" dir; the overlay model never does diff --git a/test/session-lifecycle-start.test.ts b/test/session-lifecycle-start.test.ts index 4c5ad3ed..392e5d03 100644 --- a/test/session-lifecycle-start.test.ts +++ b/test/session-lifecycle-start.test.ts @@ -7,6 +7,11 @@ const { emitHookEventMock, forkMock, execSyncMock } = vi.hoisted(() => ({ execSyncMock: vi.fn(), })); +const { prepareSessionSkillPromptMock, prepareSkillDeliveryMock } = vi.hoisted(() => ({ + prepareSessionSkillPromptMock: vi.fn((opts: any) => ({ prompt: opts.prompt, manifest: null })), + prepareSkillDeliveryMock: vi.fn(() => ({ prompt: false, readonlyRoots: [], diagnostics: [] })), +})); + vi.mock('node:child_process', async (importOriginal) => { const actual = await importOriginal(); return { @@ -72,6 +77,14 @@ vi.mock('../src/core/session-manager.js', () => ({ persistStreamCardState: vi.fn(), })); +vi.mock('../src/core/skills/session-runtime.js', () => ({ + prepareSessionSkillPrompt: (...args: unknown[]) => prepareSessionSkillPromptMock(...args), +})); + +vi.mock('../src/core/skills/delivery.js', () => ({ + prepareSkillDelivery: (...args: unknown[]) => prepareSkillDeliveryMock(...args), +})); + vi.mock('../src/core/dashboard-events.js', () => ({ dashboardEventBus: { publish: vi.fn() }, })); @@ -152,6 +165,8 @@ beforeEach(() => { vi.clearAllMocks(); __testOnly_resetSessionLifecycleHooks(); forkMock.mockImplementation(() => makeFakeWorker()); + prepareSessionSkillPromptMock.mockImplementation((opts: any) => ({ prompt: opts.prompt, manifest: null })); + prepareSkillDeliveryMock.mockReturnValue({ prompt: false, readonlyRoots: [], diagnostics: [] }); initWorkerPool({ sessionReply: vi.fn(async () => 'om_reply'), getSessionWorkingDir: () => '/repo', @@ -189,4 +204,44 @@ describe('session.start lifecycle integration', () => { pid: 12345, })); }); + + it('reports fatal skill delivery config instead of forking a worker', async () => { + const sessionReply = vi.fn(async () => 'om_reply'); + initWorkerPool({ + sessionReply, + getSessionWorkingDir: () => '/repo', + getActiveCount: () => 1, + closeSession: vi.fn(), + }); + prepareSessionSkillPromptMock.mockReturnValue({ + prompt: 'hello', + manifest: { + sessionId: 'sid-start-test', + cliId: 'codex', + workingDir: '/repo', + policyMode: 'priority', + prioritySkills: [{ name: 'deploy' }], + diagnostics: [], + generatedAt: '2026-06-14T00:00:00.000Z', + }, + }); + prepareSkillDeliveryMock.mockReturnValue({ + prompt: false, + readonlyRoots: [], + diagnostics: ['native_skill_delivery_not_supported'], + fatal: true, + }); + + forkWorker(makeDs(), 'hello', false); + await Promise.resolve(); + + expect(forkMock).not.toHaveBeenCalled(); + expect(sessionReply).toHaveBeenCalledWith( + 'om_root', + expect.stringContaining('native_skill_delivery_not_supported'), + undefined, + 'app_test', + undefined, + ); + }); }); diff --git a/test/session-skill-injection.test.ts b/test/session-skill-injection.test.ts new file mode 100644 index 00000000..12988f11 --- /dev/null +++ b/test/session-skill-injection.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { buildNewTopicPrompt } from '../src/core/session-manager.js'; +import type { SessionSkillManifest } from '../src/core/skills/types.js'; + +describe('session skill injection', () => { + it('does not change prompt when no skill manifest is provided', () => { + const base = buildNewTopicPrompt('hello', 's1', 'codex'); + + expect(base).not.toContain(' { + const manifest: SessionSkillManifest = { + sessionId: 's1', + cliId: 'codex', + workingDir: '/repo', + policyMode: 'priority', + prioritySkills: [{ + id: 'deploy', + name: 'deploy', + description: 'Deploy services', + tags: ['sre'], + rootDir: '/skills/deploy', + entrypoint: 'SKILL.md', + source: { type: 'user', root: '/skills/deploy' }, + priorityReason: 'bot:include', + }], + diagnostics: [], + generatedAt: '2026-06-14T00:00:00.000Z', + }; + + const prompt = buildNewTopicPrompt( + 'hello', + 's1', + 'codex', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { skillManifest: manifest }, + ); + + expect(prompt).toContain(''); + expect(prompt).toContain('botmux skill show deploy'); + }); +}); diff --git a/test/session-skill-manifest-resolution.test.ts b/test/session-skill-manifest-resolution.test.ts new file mode 100644 index 00000000..7259b733 --- /dev/null +++ b/test/session-skill-manifest-resolution.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveSessionSkillManifest } from '../src/core/skills/session-resolver.js'; + +describe('session skill manifest resolution', () => { + it('returns null when bot has no skill policy', () => { + const manifest = resolveSessionSkillManifest({ + sessionId: 's1', + cliId: 'codex', + workingDir: '/repo', + botPolicy: undefined, + registrySkills: [], + projectSkills: [], + now: () => '2026-06-14T00:00:00.000Z', + }); + + expect(manifest).toBeNull(); + }); + + it('builds a manifest when policy selects skills', () => { + const manifest = resolveSessionSkillManifest({ + sessionId: 's1', + cliId: 'codex', + workingDir: '/repo', + botPolicy: { include: ['skill:deploy'] }, + globalDelivery: 'prompt', + registrySkills: [{ + id: 'deploy', + name: 'deploy', + tags: [], + rootDir: '/skills/deploy', + entrypoint: 'SKILL.md', + source: { type: 'user', root: '/skills/deploy' }, + }], + projectSkills: [], + now: () => '2026-06-14T00:00:00.000Z', + }); + + expect(manifest?.prioritySkills.map((s) => s.name)).toEqual(['deploy']); + expect(manifest?.delivery).toBe('prompt'); + expect(manifest?.generatedAt).toBe('2026-06-14T00:00:00.000Z'); + }); +}); diff --git a/test/session-skill-runtime.test.ts b/test/session-skill-runtime.test.ts new file mode 100644 index 00000000..f99640eb --- /dev/null +++ b/test/session-skill-runtime.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { prepareSessionSkillPrompt } from '../src/core/skills/session-runtime.js'; +import { readSessionSkillManifest } from '../src/core/skills/manifest-store.js'; +import { installLocalSkill } from '../src/services/skill-registry-store.js'; + +function write(file: string, content: string): void { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, content); +} + +describe('session skill runtime preparation', () => { + let home: string; + let dataDir: string; + let src: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'botmux-skill-home-')); + dataDir = mkdtempSync(join(tmpdir(), 'botmux-skill-data-')); + src = mkdtempSync(join(tmpdir(), 'botmux-skill-src-')); + vi.stubEnv('HOME', home); + vi.stubEnv('SESSION_DATA_DIR', dataDir); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); + rmSync(dataDir, { recursive: true, force: true }); + rmSync(src, { recursive: true, force: true }); + }); + + it('leaves prompt unchanged and writes no manifest when bot has no skill policy', () => { + const result = prepareSessionSkillPrompt({ + sessionId: 's1', + cliId: 'codex', + workingDir: '/repo', + prompt: 'hello', + botPolicy: undefined, + }); + + expect(result.prompt).toBe('hello'); + expect(result.manifest).toBeNull(); + expect(readSessionSkillManifest('s1')).toBeNull(); + }); + + it('writes manifest and appends catalog for configured priority skills', () => { + write(join(src, 'deploy', 'SKILL.md'), '---\nname: deploy\ndescription: Deploy services\n---\n# Deploy'); + installLocalSkill(join(src, 'deploy'), { link: false }); + + const result = prepareSessionSkillPrompt({ + sessionId: 's2', + cliId: 'codex', + workingDir: '/repo', + prompt: 'hello', + botPolicy: { include: ['skill:deploy'] }, + }); + + expect(result.prompt).toContain('hello'); + expect(result.prompt).toContain(''); + expect(result.prompt).toContain('botmux skill show deploy'); + expect(readSessionSkillManifest('s2')?.prioritySkills.map((s) => s.name)).toEqual(['deploy']); + }); +}); diff --git a/test/skill-admin-command.test.ts b/test/skill-admin-command.test.ts new file mode 100644 index 00000000..f66fb9d6 --- /dev/null +++ b/test/skill-admin-command.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { runSkillsAdminCommand } from '../src/core/skills/cli-admin-command.js'; +import { readSkillRegistry } from '../src/services/skill-registry-store.js'; + +function write(file: string, content: string): void { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, content); +} + +describe('botmux skills admin command', () => { + let home: string; + let src: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'botmux-skill-home-')); + src = mkdtempSync(join(tmpdir(), 'botmux-skill-src-')); + vi.stubEnv('HOME', home); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); + rmSync(src, { recursive: true, force: true }); + }); + + it('validates, installs, lists, inspects and removes a local skill', () => { + const dir = join(src, 'deploy'); + write(join(dir, 'SKILL.md'), '---\nname: deploy\ndescription: Deploy services\n---\n# Deploy'); + + expect(runSkillsAdminCommand(['validate', dir])).toMatchObject({ code: 0 }); + expect(runSkillsAdminCommand(['install', dir]).stdout).toContain('installed deploy'); + expect(runSkillsAdminCommand(['list']).stdout).toContain('deploy'); + expect(runSkillsAdminCommand(['inspect', 'deploy']).stdout).toContain('"name": "deploy"'); + expect(readSkillRegistry().skills.deploy).toBeDefined(); + expect(runSkillsAdminCommand(['remove', 'deploy']).stdout).toContain('removed deploy'); + expect(readSkillRegistry().skills.deploy).toBeUndefined(); + }); + + it('updates an installed local-copy skill from its recorded source', () => { + const dir = join(src, 'deploy'); + write(join(dir, 'SKILL.md'), '---\nname: deploy\ndescription: Old\n---\n# Deploy'); + runSkillsAdminCommand(['install', dir]); + write(join(dir, 'SKILL.md'), '---\nname: deploy\ndescription: New\n---\n# Deploy'); + + const updated = runSkillsAdminCommand(['update', 'deploy']); + + expect(updated.stdout).toContain('updated deploy'); + expect(readSkillRegistry().skills.deploy.description).toBe('New'); + }); + + it('requires --force when removing a skill referenced by bot policy', () => { + const dir = join(src, 'deploy'); + const botsPath = join(home, 'bots.json'); + write(join(dir, 'SKILL.md'), '---\nname: deploy\n---\n# Deploy'); + write(botsPath, JSON.stringify([{ + larkAppId: 'app-1', + larkAppSecret: 'secret', + name: 'ops-bot', + cliId: 'codex', + skills: { include: ['skill:deploy'] }, + }])); + vi.stubEnv('BOTS_CONFIG', botsPath); + runSkillsAdminCommand(['install', dir]); + + const blocked = runSkillsAdminCommand(['remove', 'deploy']); + + expect(blocked.code).toBe(1); + expect(blocked.stderr).toContain('skill_in_use'); + expect(blocked.stderr).toContain('ops-bot'); + expect(readSkillRegistry().skills.deploy).toBeDefined(); + + expect(runSkillsAdminCommand(['remove', 'deploy', '--force']).stdout).toContain('removed deploy'); + expect(readSkillRegistry().skills.deploy).toBeUndefined(); + }); + + it('reports missing skill before checking dangling bot references', () => { + const botsPath = join(home, 'bots.json'); + write(botsPath, JSON.stringify([{ + larkAppId: 'app-1', + larkAppSecret: 'secret', + name: 'ops-bot', + cliId: 'codex', + skills: { include: ['skill:deploy'] }, + }])); + vi.stubEnv('BOTS_CONFIG', botsPath); + + const result = runSkillsAdminCommand(['remove', 'deploy']); + + expect(result.code).toBe(1); + expect(result.stderr).toBe('skill_not_installed\n'); + }); + +}); diff --git a/test/skill-claude-delivery.test.ts b/test/skill-claude-delivery.test.ts new file mode 100644 index 00000000..0b2b92a2 --- /dev/null +++ b/test/skill-claude-delivery.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { createCliAdapterSync } from '../src/adapters/cli/registry.js'; +import { prepareClaudeSkillPlugin } from '../src/core/skills/claude-plugin-delivery.js'; +import { prepareSkillDelivery } from '../src/core/skills/delivery.js'; +import type { SessionSkillManifest } from '../src/core/skills/types.js'; + +function write(file: string, content: string): void { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, content); +} + +describe('Claude scoped skill delivery', () => { + let root: string; + let dataDir: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'botmux-skill-plugin-')); + dataDir = mkdtempSync(join(tmpdir(), 'botmux-data-')); + vi.stubEnv('SESSION_DATA_DIR', dataDir); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(root, { recursive: true, force: true }); + rmSync(dataDir, { recursive: true, force: true }); + }); + + it('materializes selected skills into a Claude plugin root', () => { + write(join(root, 'deploy', 'SKILL.md'), '# Deploy'); + const manifest: SessionSkillManifest = { + sessionId: 's1', + cliId: 'claude-code', + workingDir: '/repo', + policyMode: 'priority', + prioritySkills: [{ + id: 'deploy', + name: 'deploy', + tags: [], + rootDir: join(root, 'deploy'), + entrypoint: 'SKILL.md', + source: { type: 'user', root: join(root, 'deploy') }, + priorityReason: 'bot:include', + }], + diagnostics: [], + generatedAt: '2026-06-14T00:00:00.000Z', + }; + + const prepared = prepareClaudeSkillPlugin(manifest); + + expect(readFileSync(join(prepared.pluginDir, '.claude-plugin', 'plugin.json'), 'utf-8')).toContain('"name": "botmux-session-skills"'); + expect(readFileSync(join(prepared.pluginDir, 'skills', 'deploy', 'SKILL.md'), 'utf-8')).toContain('# Deploy'); + }); + + it('Claude adapter appends the session skill plugin dir without replacing botmux plugin dir', () => { + const adapter = createCliAdapterSync('claude-code'); + const args = adapter.buildArgs({ sessionId: 's1', resume: false, skillPluginDir: '/tmp/session-plugin' }); + const pluginDirs = args.flatMap((arg, index) => arg === '--plugin-dir' ? [args[index + 1]] : []); + + expect(pluginDirs).toContain('/tmp/session-plugin'); + expect(pluginDirs.length).toBeGreaterThan(1); + }); + + it('fails native delivery explicitly when the CLI has no scoped native support', () => { + write(join(root, 'deploy', 'SKILL.md'), '# Deploy'); + const manifest: SessionSkillManifest = { + sessionId: 's1', + cliId: 'codex', + workingDir: '/repo', + policyMode: 'priority', + prioritySkills: [{ + id: 'deploy', + name: 'deploy', + tags: [], + rootDir: join(root, 'deploy'), + entrypoint: 'SKILL.md', + source: { type: 'user', root: join(root, 'deploy') }, + priorityReason: 'bot:include', + }], + diagnostics: [], + generatedAt: '2026-06-14T00:00:00.000Z', + }; + + const prepared = prepareSkillDelivery(createCliAdapterSync('codex'), manifest, 'native'); + + expect(prepared.fatal).toBe(true); + expect(prepared.diagnostics).toContain('native_skill_delivery_not_supported'); + }); +}); diff --git a/test/skill-cli-commands.test.ts b/test/skill-cli-commands.test.ts new file mode 100644 index 00000000..8c481818 --- /dev/null +++ b/test/skill-cli-commands.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { runSkillSessionCommand } from '../src/core/skills/cli-session-command.js'; +import { writeSessionSkillManifest } from '../src/core/skills/manifest-store.js'; + +function write(file: string, content: string): void { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, content); +} + +describe('botmux skill session command', () => { + let dataDir: string; + let skillDir: string; + + beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), 'botmux-skill-data-')); + skillDir = mkdtempSync(join(tmpdir(), 'botmux-skill-dir-')); + vi.stubEnv('SESSION_DATA_DIR', dataDir); + write(join(skillDir, 'SKILL.md'), '# Deploy'); + write(join(skillDir, 'references', 'release.md'), '# Release'); + writeSessionSkillManifest({ + sessionId: 's1', + cliId: 'codex', + workingDir: '/repo', + policyMode: 'priority', + prioritySkills: [{ + id: 'deploy', + name: 'deploy', + description: 'Deploy services', + tags: [], + rootDir: skillDir, + entrypoint: 'SKILL.md', + source: { type: 'user', root: skillDir }, + priorityReason: 'bot:include', + }], + diagnostics: [], + generatedAt: '2026-06-14T00:00:00.000Z', + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(dataDir, { recursive: true, force: true }); + rmSync(skillDir, { recursive: true, force: true }); + }); + + it('lists skills from the current session manifest', () => { + expect(runSkillSessionCommand(['list'], { BOTMUX_SESSION_ID: 's1' }).stdout).toContain('deploy'); + }); + + it('shows SKILL.md entrypoint', () => { + expect(runSkillSessionCommand(['show', 'deploy'], { BOTMUX_SESSION_ID: 's1' }).stdout).toContain('# Deploy'); + }); + + it('reads relative resources', () => { + expect(runSkillSessionCommand(['read', 'deploy', 'references/release.md'], { BOTMUX_SESSION_ID: 's1' }).stdout).toContain('# Release'); + }); + + it('refuses to run without a session id', () => { + expect(runSkillSessionCommand(['list'], {}).stderr).toContain('missing BOTMUX_SESSION_ID'); + }); +}); diff --git a/test/skill-discovery.test.ts b/test/skill-discovery.test.ts new file mode 100644 index 00000000..11f1e6f4 --- /dev/null +++ b/test/skill-discovery.test.ts @@ -0,0 +1,30 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { discoverProjectSkills } from '../src/core/skills/discovery.js'; + +function write(file: string, content: string): void { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, content); +} + +describe('skill discovery', () => { + let repo: string; + + beforeEach(() => { + repo = mkdtempSync(join(tmpdir(), 'botmux-skill-repo-')); + }); + + afterEach(() => { + rmSync(repo, { recursive: true, force: true }); + }); + + it('discovers project skills from .agents/skills and .botmux/skills', () => { + write(join(repo, '.agents', 'skills', 'agent-skill', 'SKILL.md'), '---\nname: agent-skill\n---'); + write(join(repo, '.botmux', 'skills', 'botmux-skill', 'SKILL.md'), '---\nname: botmux-skill\n---'); + + expect(discoverProjectSkills(repo).map((s) => s.name).sort()).toEqual(['agent-skill', 'botmux-skill']); + }); +}); diff --git a/test/skill-doctor-command.test.ts b/test/skill-doctor-command.test.ts new file mode 100644 index 00000000..59aacd41 --- /dev/null +++ b/test/skill-doctor-command.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { runSkillsAdminCommand } from '../src/core/skills/cli-admin-command.js'; +import { installLocalSkill, readSkillRegistry } from '../src/services/skill-registry-store.js'; + +function write(file: string, content: string): void { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, content); +} + +function writeBots(home: string, bots: unknown[]): void { + write(join(home, '.botmux', 'bots.json'), JSON.stringify(bots, null, 2)); +} + +describe('botmux skills diagnostics commands', () => { + let home: string; + let src: string; + let repo: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'botmux-skill-home-')); + src = mkdtempSync(join(tmpdir(), 'botmux-skill-src-')); + repo = mkdtempSync(join(tmpdir(), 'botmux-skill-repo-')); + vi.stubEnv('HOME', home); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); + rmSync(src, { recursive: true, force: true }); + rmSync(repo, { recursive: true, force: true }); + }); + + it('reports broken installed skills in doctor output', () => { + write(join(src, 'deploy', 'SKILL.md'), '---\nname: deploy\n---\n# Deploy'); + installLocalSkill(join(src, 'deploy'), { link: false }); + rmSync(readSkillRegistry().skills.deploy.rootDir, { recursive: true, force: true }); + + const result = runSkillsAdminCommand(['doctor']); + + expect(result.code).toBe(1); + expect(result.stdout).toContain('broken\tdeploy\tmissing_root'); + }); + + it('shows default CLI behavior when a bot has no custom skill policy', () => { + writeBots(home, [{ + larkAppId: 'app-default', + larkAppSecret: 'secret', + cliId: 'codex', + name: 'default', + }]); + + const result = runSkillsAdminCommand(['resolve', '--bot', 'default', '--cwd', repo]); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('skills: default'); + expect(result.stdout).toContain('CLI-native skills remain unchanged'); + }); + + it('resolves bot priority skills and explains delivery per CLI', () => { + write(join(src, 'deploy', 'SKILL.md'), [ + '---', + 'name: deploy', + 'description: Deploy services', + 'tags: [sre]', + '---', + '# Deploy', + ].join('\n')); + installLocalSkill(join(src, 'deploy'), { link: false }); + writeBots(home, [{ + larkAppId: 'app-skilled', + larkAppSecret: 'secret', + cliId: 'codex', + name: 'skilled', + skills: { include: ['skill:deploy'] }, + }]); + + const resolved = runSkillsAdminCommand(['resolve', '--bot', 'skilled', '--cwd', repo]); + const codex = runSkillsAdminCommand(['delivery', '--bot', 'skilled']); + const claude = runSkillsAdminCommand(['delivery', '--cli', 'claude-code', '--mode', 'auto']); + + expect(resolved.stdout).toContain('deploy\tbot:include\tDeploy services'); + expect(codex.stdout).toContain('delivery: prompt'); + expect(claude.stdout).toContain('delivery: hybrid'); + }); +}); diff --git a/test/skill-git-install.test.ts b/test/skill-git-install.test.ts new file mode 100644 index 00000000..5f17ce9e --- /dev/null +++ b/test/skill-git-install.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { + installGitSkill, + installGitSkillAsync, + readSkillRegistry, + removeInstalledSkill, + updateInstalledSkill, + updateInstalledSkillAsync, +} from '../src/services/skill-registry-store.js'; + +function run(cmd: string, args: string[], cwd: string): string { + return execFileSync(cmd, args, { cwd, encoding: 'utf-8' }).trim(); +} + +function write(file: string, content: string): void { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, content); +} + +describe('git skill install', () => { + let home: string; + let repo: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'botmux-skill-home-')); + repo = mkdtempSync(join(tmpdir(), 'botmux-skill-repo-')); + vi.stubEnv('HOME', home); + run('git', ['init'], repo); + run('git', ['config', 'user.email', 'botmux@example.com'], repo); + run('git', ['config', 'user.name', 'botmux'], repo); + write(join(repo, 'skills', 'deploy', 'SKILL.md'), '---\nname: deploy\n---\n# Deploy'); + run('git', ['add', '.'], repo); + run('git', ['commit', '-m', 'add deploy skill'], repo); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); + rmSync(repo, { recursive: true, force: true }); + }); + + it('installs a skill from git path and records the checked out commit', () => { + const commit = run('git', ['rev-parse', 'HEAD'], repo); + + const pkg = installGitSkill({ url: repo, path: 'skills/deploy', ref: 'HEAD' }); + + expect(pkg.name).toBe('deploy'); + expect(readSkillRegistry().skills.deploy.source).toMatchObject({ + type: 'git', + url: repo, + path: 'skills/deploy', + ref: 'HEAD', + commit, + }); + }); + + it('updates an installed git skill from its recorded source', () => { + installGitSkill({ url: repo, path: 'skills/deploy', ref: 'HEAD' }); + write(join(repo, 'skills', 'deploy', 'SKILL.md'), '---\nname: deploy\ndescription: Updated\n---\n# Deploy'); + run('git', ['add', '.'], repo); + run('git', ['commit', '-m', 'update deploy skill'], repo); + const commit = run('git', ['rev-parse', 'HEAD'], repo); + + const result = updateInstalledSkill('deploy'); + + expect(result.ok).toBe(true); + expect(readSkillRegistry().skills.deploy.description).toBe('Updated'); + expect(readSkillRegistry().skills.deploy.source).toMatchObject({ commit }); + }); + + it('removes the store copy for git installs', () => { + const pkg = installGitSkill({ url: repo, path: 'skills/deploy', ref: 'HEAD' }); + expect(existsSync(pkg.rootDir)).toBe(true); + + const result = removeInstalledSkill('deploy'); + + expect(result).toEqual({ ok: true }); + expect(readSkillRegistry().skills.deploy).toBeUndefined(); + expect(existsSync(pkg.rootDir)).toBe(false); + }); + + it('rejects git skill paths outside the cached checkout', () => { + expect(() => installGitSkill({ url: repo, path: '../deploy', ref: 'HEAD' })).toThrow(/invalid_git_skill_path/); + }); + + it('reports git_not_found when git is unavailable', () => { + vi.stubEnv('PATH', join(home, 'missing-bin')); + + expect(() => installGitSkill({ url: repo, path: 'skills/deploy', ref: 'HEAD' })).toThrow(/^git_not_found$/); + }); + + it('rejects git skill paths that resolve outside through symlinks', () => { + const outside = mkdtempSync(join(tmpdir(), 'botmux-skill-outside-')); + write(join(outside, 'SKILL.md'), '---\nname: outside\n---\n# Outside'); + symlinkSync(outside, join(repo, 'skills', 'outside-link')); + run('git', ['add', '.'], repo); + run('git', ['commit', '-m', 'add outside symlink'], repo); + + try { + expect(() => installGitSkill({ url: repo, path: 'skills/outside-link', ref: 'HEAD' })).toThrow(/git_skill_path_outside_repo/); + } finally { + rmSync(outside, { recursive: true, force: true }); + } + }); + + it('supports async install and update for dashboard jobs', async () => { + await installGitSkillAsync({ url: repo, path: 'skills/deploy', ref: 'HEAD' }); + write(join(repo, 'skills', 'deploy', 'SKILL.md'), '---\nname: deploy\ndescription: Async Updated\n---\n# Deploy'); + run('git', ['add', '.'], repo); + run('git', ['commit', '-m', 'async update deploy skill'], repo); + + const result = await updateInstalledSkillAsync('deploy'); + + expect(result.ok).toBe(true); + expect(readSkillRegistry().skills.deploy.description).toBe('Async Updated'); + }); + + it('serializes concurrent async installs from the same git source', async () => { + const firstCommit = run('git', ['rev-parse', 'HEAD'], repo); + write(join(repo, 'skills', 'deploy', 'SKILL.md'), '---\nname: deploy\ndescription: Updated\n---\n# Deploy'); + write(join(repo, 'skills', 'analyze', 'SKILL.md'), '---\nname: analyze\ndescription: Analyze\n---\n# Analyze'); + run('git', ['add', '.'], repo); + run('git', ['commit', '-m', 'add analyze skill'], repo); + const secondCommit = run('git', ['rev-parse', 'HEAD'], repo); + + const [deploy, analyze] = await Promise.all([ + installGitSkillAsync({ url: repo, path: 'skills/deploy', ref: firstCommit }), + installGitSkillAsync({ url: repo, path: 'skills/analyze', ref: secondCommit }), + ]); + + expect(deploy.name).toBe('deploy'); + expect(deploy.description).toBeUndefined(); + expect(deploy.source).toMatchObject({ type: 'git', commit: firstCommit }); + expect(analyze.name).toBe('analyze'); + expect(analyze.description).toBe('Analyze'); + expect(analyze.source).toMatchObject({ type: 'git', commit: secondCommit }); + }); +}); diff --git a/test/skill-im-command.test.ts b/test/skill-im-command.test.ts new file mode 100644 index 00000000..d9fdbca4 --- /dev/null +++ b/test/skill-im-command.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BotSkillPolicy } from '../src/core/skills/types.js'; + +const botConfig: { skills?: BotSkillPolicy } = {}; +const registry = { + skills: { + deploy: { + id: 'deploy', + name: 'deploy', + description: 'Deploy services', + tags: [], + rootDir: '/skills/deploy', + entrypoint: 'SKILL.md', + source: { type: 'local-copy', originalPath: '/src/deploy' }, + }, + }, +}; + +vi.mock('../src/bot-registry.js', () => ({ + getBot: vi.fn(() => ({ + botName: 'Test Bot', + resolvedAllowedUsers: ['ou_admin'], + config: { + larkAppId: 'app-1', + larkAppSecret: 'secret', + cliId: 'codex', + ...botConfig, + }, + })), +})); + +vi.mock('../src/services/skill-registry-store.js', () => ({ + readSkillRegistry: vi.fn(() => registry), +})); + +vi.mock('../src/services/bot-config-store.js', () => ({ + findConfigField: vi.fn(() => ({ key: 'skills', configKey: 'skills', kind: 'json', effect: 'next-session', clearable: true })), + applyConfigField: vi.fn(async (_appId: string, _spec: unknown, value: unknown) => { + if (value === null) delete botConfig.skills; + else botConfig.skills = value as BotSkillPolicy; + return { ok: true, oldText: '', newText: '', effect: 'next-session' }; + }), +})); + +import { attachSkillPolicy, detachSkillPolicy, runSkillsImCommand } from '../src/core/skills/im-command.js'; + +describe('/skills IM command', () => { + beforeEach(() => { + delete botConfig.skills; + }); + + it('attaches an installed registry skill as a priority selector', async () => { + const result = await runSkillsImCommand('app-1', '/skills attach deploy'); + + expect(result.ok).toBe(true); + expect(botConfig.skills?.include).toEqual(['skill:deploy']); + }); + + it('does not duplicate an existing attached skill selector', () => { + const next = attachSkillPolicy({ include: ['skill:deploy'] }, 'deploy'); + + expect(next.include).toEqual(['skill:deploy']); + }); + + it('clears the policy when detaching the last custom skill', async () => { + botConfig.skills = { include: ['skill:deploy'] }; + + const result = await runSkillsImCommand('app-1', '/skills detach deploy'); + + expect(result.ok).toBe(true); + expect(botConfig.skills).toBeUndefined(); + }); + + it('drops unsupported non-direct selectors when detaching', () => { + const next = detachSkillPolicy({ include: ['skill:deploy', 'tag:sre'] as any }, 'deploy'); + + expect(next).toBeUndefined(); + }); + + it('rejects attaching a skill that is not installed', async () => { + const result = await runSkillsImCommand('app-1', '/skills attach missing'); + + expect(result.ok).toBe(false); + expect(result.message).toContain('未安装 skill'); + }); +}); diff --git a/test/skill-manifest-store.test.ts b/test/skill-manifest-store.test.ts new file mode 100644 index 00000000..58bbc5de --- /dev/null +++ b/test/skill-manifest-store.test.ts @@ -0,0 +1,37 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { readSessionSkillManifest, writeSessionSkillManifest } from '../src/core/skills/manifest-store.js'; +import type { SessionSkillManifest } from '../src/core/skills/types.js'; + +describe('session skill manifest store', () => { + let dataDir: string; + + beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), 'botmux-skill-data-')); + vi.stubEnv('SESSION_DATA_DIR', dataDir); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(dataDir, { recursive: true, force: true }); + }); + + it('writes and reads a manifest by session id', () => { + const manifest: SessionSkillManifest = { + sessionId: 's1', + cliId: 'codex', + workingDir: '/repo', + policyMode: 'priority', + prioritySkills: [], + diagnostics: [], + generatedAt: '2026-06-14T00:00:00.000Z', + }; + + writeSessionSkillManifest(manifest); + + expect(readSessionSkillManifest('s1')).toEqual(manifest); + }); +}); diff --git a/test/skill-package.test.ts b/test/skill-package.test.ts new file mode 100644 index 00000000..bd712726 --- /dev/null +++ b/test/skill-package.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { loadSkillPackage, validateSkillPackageDir } from '../src/core/skills/package.js'; + +function write(file: string, content: string): void { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, content); +} + +describe('skill package parser', () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'botmux-skill-pkg-')); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('loads metadata from SKILL.md frontmatter', () => { + const dir = join(root, 'deploy-runbook'); + write(join(dir, 'SKILL.md'), [ + '---', + 'name: deploy-runbook', + 'description: Use when deploying services', + 'version: 1.2.0', + 'tags: [deploy, sre]', + '---', + '', + '# Deploy', + ].join('\n')); + + const pkg = loadSkillPackage(dir, { source: { type: 'user', root: dir } }); + + expect(pkg.name).toBe('deploy-runbook'); + expect(pkg.description).toBe('Use when deploying services'); + expect(pkg.version).toBe('1.2.0'); + expect(pkg.tags).toEqual(['deploy', 'sre']); + expect(pkg.entrypoint).toBe('SKILL.md'); + }); + + it('uses the directory name when frontmatter has no name', () => { + const dir = join(root, 'fallback-name'); + write(join(dir, 'SKILL.md'), '# Body'); + + expect(loadSkillPackage(dir, { source: { type: 'user', root: dir } }).name).toBe('fallback-name'); + }); + + it('rejects a directory without SKILL.md', () => { + const dir = join(root, 'broken'); + mkdirSync(dir, { recursive: true }); + + expect(validateSkillPackageDir(dir)).toEqual({ ok: false, reason: 'missing_skill_md' }); + }); + + it('rejects invalid skill names', () => { + const dir = join(root, 'bad-name'); + write(join(dir, 'SKILL.md'), '---\nname: bad name\n---\n# Bad'); + + expect(() => loadSkillPackage(dir, { source: { type: 'user', root: dir } })).toThrow(/invalid_skill_name/); + }); +}); diff --git a/test/skill-policy-resolver.test.ts b/test/skill-policy-resolver.test.ts new file mode 100644 index 00000000..e4719189 --- /dev/null +++ b/test/skill-policy-resolver.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveSkillPolicy } from '../src/core/skills/policy.js'; +import type { SkillPackage } from '../src/core/skills/types.js'; + +function pkg(name: string, tags: string[], source: SkillPackage['source']): SkillPackage { + return { + id: name, + name, + tags, + rootDir: `/tmp/${name}`, + entrypoint: 'SKILL.md', + source, + description: `${name} description`, + }; +} + +describe('skill policy resolver', () => { + it('is disabled when bot policy is absent', () => { + const result = resolveSkillPolicy({ + registrySkills: [pkg('deploy', ['sre'], { type: 'user', root: '/tmp/deploy' })], + projectSkills: [], + botPolicy: undefined, + workingDir: '/repo', + }); + + expect(result.enabled).toBe(false); + expect(result.prioritySkills).toEqual([]); + }); + + it('resolves direct skill includes only', () => { + const result = resolveSkillPolicy({ + registrySkills: [ + pkg('design-review', ['frontend'], { type: 'user', root: '/tmp/design-review' }), + pkg('deploy', ['sre'], { type: 'user', root: '/tmp/deploy' }), + pkg('old-release', ['sre'], { type: 'user', root: '/tmp/old-release' }), + ], + projectSkills: [], + botPolicy: { + include: ['skill:deploy', 'tag:frontend'] as any, + } as any, + workingDir: '/repo', + }); + + expect(result.enabled).toBe(true); + expect(result.prioritySkills.map((s) => s.name)).toEqual(['deploy']); + }); + + it('diagnoses duplicate names and keeps one selected skill', () => { + const result = resolveSkillPolicy({ + registrySkills: [pkg('deploy', ['sre'], { type: 'user', root: '/user/deploy' })], + projectSkills: [pkg('deploy', ['repo'], { type: 'project', root: '/repo/.agents/skills/deploy' })], + globalProjectSkills: 'all', + botPolicy: { include: ['skill:deploy'] }, + workingDir: '/repo', + }); + + expect(result.prioritySkills).toHaveLength(1); + expect(result.diagnostics.some((d) => d.code === 'duplicate_skill_shadowed')).toBe(true); + }); + + it('uses global project skill trust and ignores bot-level project overrides', () => { + const projectSkills = [pkg('deploy', ['repo'], { type: 'project', root: '/repo/.agents/skills/deploy' })]; + const trusted = resolveSkillPolicy({ + registrySkills: [], + projectSkills, + globalProjectSkills: 'trusted', + botPolicy: { include: ['skill:deploy'] }, + workingDir: '/repo', + }); + const botOverrideIgnored = resolveSkillPolicy({ + registrySkills: [], + projectSkills, + globalProjectSkills: 'trusted', + botPolicy: { include: ['skill:deploy'], projectSkills: 'off' } as any, + workingDir: '/repo', + }); + + expect(trusted.prioritySkills.map((s) => s.name)).toEqual(['deploy']); + expect(trusted.diagnostics.some((d) => d.code === 'project_skills_trusted_deprecated')).toBe(true); + expect(botOverrideIgnored.prioritySkills.map((s) => s.name)).toEqual(['deploy']); + }); + + it('uses the global delivery default and ignores bot-level delivery overrides', () => { + const globalDefault = resolveSkillPolicy({ + registrySkills: [pkg('deploy', ['sre'], { type: 'user', root: '/tmp/deploy' })], + projectSkills: [], + globalDelivery: 'prompt', + botPolicy: { include: ['skill:deploy'] }, + workingDir: '/repo', + }); + const botOverride = resolveSkillPolicy({ + registrySkills: [pkg('deploy', ['sre'], { type: 'user', root: '/tmp/deploy' })], + projectSkills: [], + globalDelivery: 'prompt', + botPolicy: { include: ['skill:deploy'], delivery: 'native' } as any, + workingDir: '/repo', + }); + + expect(globalDefault.delivery).toBe('prompt'); + expect(botOverride.delivery).toBe('prompt'); + }); +}); diff --git a/test/skill-prompt.test.ts b/test/skill-prompt.test.ts new file mode 100644 index 00000000..4173d437 --- /dev/null +++ b/test/skill-prompt.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { renderSkillCatalogBlock } from '../src/core/skills/prompt.js'; +import type { SessionSkillManifest } from '../src/core/skills/types.js'; + +function manifest(): SessionSkillManifest { + return { + sessionId: 's1', + cliId: 'codex', + workingDir: '/repo', + policyMode: 'priority', + prioritySkills: [{ + id: 'deploy', + name: 'deploy', + description: 'Deploy services', + tags: ['sre'], + rootDir: '/skills/deploy', + entrypoint: 'SKILL.md', + source: { type: 'user', root: '/skills/deploy' }, + priorityReason: 'bot:include', + }], + diagnostics: [], + generatedAt: '2026-06-14T00:00:00.000Z', + }; +} + +describe('skill prompt catalog', () => { + it('renders empty string when manifest is null or has no skills', () => { + expect(renderSkillCatalogBlock(null)).toBe(''); + expect(renderSkillCatalogBlock({ ...manifest(), prioritySkills: [] })).toBe(''); + }); + + it('renders priority skill metadata and botmux skill commands', () => { + const block = renderSkillCatalogBlock(manifest()); + + expect(block).toContain(''); + expect(block).toContain('name="deploy"'); + expect(block).toContain('botmux skill show deploy'); + }); +}); diff --git a/test/skill-registry-store.test.ts b/test/skill-registry-store.test.ts new file mode 100644 index 00000000..09767305 --- /dev/null +++ b/test/skill-registry-store.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { skillRegistryPath } from '../src/core/skills/registry-paths.js'; +import { installLocalSkill, readSkillRegistry, removeInstalledSkill } from '../src/services/skill-registry-store.js'; + +function write(file: string, content: string): void { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, content); +} + +describe('skill registry store', () => { + let home: string; + let src: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'botmux-skill-home-')); + src = mkdtempSync(join(tmpdir(), 'botmux-skill-src-')); + vi.stubEnv('HOME', home); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); + rmSync(src, { recursive: true, force: true }); + }); + + it('installs a local copy into the botmux store and records registry metadata', () => { + write(join(src, 'deploy', 'SKILL.md'), '---\nname: deploy\n---\n# Deploy'); + + const pkg = installLocalSkill(join(src, 'deploy'), { link: false }); + + expect(pkg.name).toBe('deploy'); + expect(pkg.rootDir).toContain(join('.botmux', 'skills', 'store', 'deploy')); + expect(readSkillRegistry().skills.deploy.name).toBe('deploy'); + expect(readFileSync(skillRegistryPath(), 'utf-8')).toContain('local-copy'); + }); + + it('installs a local link without copying files', () => { + write(join(src, 'review', 'SKILL.md'), '---\nname: review\n---\n# Review'); + + const pkg = installLocalSkill(join(src, 'review'), { link: true }); + + expect(pkg.rootDir).toBe(realpathSync(join(src, 'review'))); + expect(readSkillRegistry().skills.review.source.type).toBe('local-link'); + }); + + it('removes the registry entry and store copy for local-copy installs', () => { + write(join(src, 'cleanup', 'SKILL.md'), '---\nname: cleanup\n---\n# Cleanup'); + const pkg = installLocalSkill(join(src, 'cleanup'), { link: false }); + + const result = removeInstalledSkill('cleanup'); + + expect(result).toEqual({ ok: true }); + expect(readSkillRegistry().skills.cleanup).toBeUndefined(); + expect(() => readFileSync(join(pkg.rootDir, 'SKILL.md'), 'utf-8')).toThrow(); + }); + + it('rejects reinstalling a local copy from its own store target without deleting it', () => { + write(join(src, 'deploy', 'SKILL.md'), '---\nname: deploy\n---\n# Deploy'); + const pkg = installLocalSkill(join(src, 'deploy'), { link: false }); + + expect(() => installLocalSkill(pkg.rootDir, { link: false })).toThrow(/local_skill_source_overlaps_store_target/); + expect(readFileSync(join(pkg.rootDir, 'SKILL.md'), 'utf-8')).toContain('name: deploy'); + expect(readSkillRegistry().skills.deploy.rootDir).toBe(pkg.rootDir); + }); +}); diff --git a/test/skill-resource-reader.test.ts b/test/skill-resource-reader.test.ts new file mode 100644 index 00000000..119f365c --- /dev/null +++ b/test/skill-resource-reader.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { listSkillResources, readSkillResource } from '../src/core/skills/resource-reader.js'; +import type { SessionSkillManifest } from '../src/core/skills/types.js'; + +function write(file: string, content: string): void { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, content); +} + +describe('skill resource reader', () => { + let root: string; + let manifest: SessionSkillManifest; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'botmux-skill-resource-')); + write(join(root, 'deploy', 'SKILL.md'), '# Deploy'); + write(join(root, 'deploy', 'references', 'release.md'), '# Release'); + manifest = { + sessionId: 's1', + cliId: 'codex', + workingDir: '/repo', + policyMode: 'priority', + prioritySkills: [{ + id: 'deploy', + name: 'deploy', + tags: [], + rootDir: join(root, 'deploy'), + entrypoint: 'SKILL.md', + source: { type: 'user', root: join(root, 'deploy') }, + priorityReason: 'bot:include', + }], + diagnostics: [], + generatedAt: '2026-06-14T00:00:00.000Z', + }; + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('reads files inside the skill root', () => { + expect(readSkillResource(manifest, 'deploy', 'references/release.md').content).toContain('# Release'); + }); + + it('rejects path traversal', () => { + expect(() => readSkillResource(manifest, 'deploy', '../secret.txt')).toThrow(/path_outside_skill_root/); + }); + + it('rejects symlink traversal', () => { + write(join(root, 'secret.txt'), 'secret'); + symlinkSync(join(root, 'secret.txt'), join(root, 'deploy', 'references', 'secret-link.md')); + + expect(() => readSkillResource(manifest, 'deploy', 'references/secret-link.md')).toThrow(/path_outside_skill_root/); + }); + + it('does not enumerate resources through symlinks outside the skill root', () => { + const outside = mkdtempSync(join(tmpdir(), 'botmux-skill-outside-')); + try { + write(join(outside, 'secret.md'), '# Secret'); + symlinkSync(outside, join(root, 'deploy', 'references', 'outside')); + + expect(listSkillResources(manifest, 'deploy')).toEqual(['SKILL.md', 'references/release.md']); + } finally { + rmSync(outside, { recursive: true, force: true }); + } + }); +}); diff --git a/test/skill-sources.test.ts b/test/skill-sources.test.ts new file mode 100644 index 00000000..479aa709 --- /dev/null +++ b/test/skill-sources.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { assertSafeGitSkillPath, parseSkillInstallSource, redactGitUrlCredentials } from '../src/core/skills/sources.js'; + +describe('skill install sources', () => { + it('rejects HTTPS git URLs with embedded credentials', () => { + expect(() => parseSkillInstallSource('git+https://token@example.com/acme/skills.git')).toThrow(/git_url_credentials_not_allowed/); + expect(() => parseSkillInstallSource('https://user:secret@example.com/acme/skills.git')).toThrow(/git_url_credentials_not_allowed/); + expect(() => parseSkillInstallSource('https://user:secret@example.com/acme/skills')).toThrow(/git_url_credentials_not_allowed/); + }); + + it('redacts URL credentials for display and errors', () => { + expect(redactGitUrlCredentials('https://user:secret@example.com/acme/skills.git')) + .toBe('https://***:***@example.com/acme/skills.git'); + expect(redactGitUrlCredentials('git+https://token@example.com/acme/skills.git')) + .toBe('git+https://***@example.com/acme/skills.git'); + }); + + it('allows SSH-style git sources', () => { + expect(parseSkillInstallSource('git@github.com:acme/skills.git')).toMatchObject({ + kind: 'git', + value: 'git@github.com:acme/skills.git', + }); + }); + + it('keeps local relative paths local', () => { + expect(parseSkillInstallSource('../skills/deploy')).toMatchObject({ + kind: 'local', + value: '../skills/deploy', + }); + }); + + it('rejects unsafe git skill paths', () => { + expect(() => assertSafeGitSkillPath('../deploy')).toThrow(/invalid_git_skill_path/); + expect(() => assertSafeGitSkillPath('skills/../deploy')).toThrow(/invalid_git_skill_path/); + expect(() => assertSafeGitSkillPath('/tmp/deploy')).toThrow(/invalid_git_skill_path/); + expect(() => assertSafeGitSkillPath('C:\\skills\\deploy')).toThrow(/invalid_git_skill_path/); + expect(() => assertSafeGitSkillPath('skills/deploy\0x')).toThrow(/invalid_git_skill_path/); + expect(() => assertSafeGitSkillPath('skills/deploy')).not.toThrow(); + expect(() => assertSafeGitSkillPath('.')).not.toThrow(); + }); + + it('rejects unsafe paths in GitHub shorthand sources', () => { + expect(() => parseSkillInstallSource('github:acme/skills/../deploy')).toThrow(/invalid_git_skill_path/); + expect(parseSkillInstallSource('github:acme/skills/skills/deploy')).toMatchObject({ + kind: 'github', + github: { owner: 'acme', repo: 'skills', path: 'skills/deploy' }, + }); + }); + + it('parses copy-pasted GitHub browser URLs', () => { + expect(parseSkillInstallSource('https://github.com/acme/skills/tree/main/skills/deploy')).toMatchObject({ + kind: 'github', + github: { owner: 'acme', repo: 'skills', ref: 'main', path: 'skills/deploy' }, + }); + expect(parseSkillInstallSource('https://github.com/acme/skills/tree/feature/foo/skills/deploy')).toMatchObject({ + kind: 'github', + github: { owner: 'acme', repo: 'skills', ref: 'feature/foo', path: 'skills/deploy' }, + }); + expect(parseSkillInstallSource('https://github.com/acme/skills')).toMatchObject({ + kind: 'github', + github: { owner: 'acme', repo: 'skills' }, + }); + expect(parseSkillInstallSource('https://github.com/acme/skills/blob/main/skills/deploy/SKILL.md')).toMatchObject({ + kind: 'github', + github: { owner: 'acme', repo: 'skills', ref: 'main', path: 'skills/deploy' }, + }); + }); + + it('rejects unsafe paths in GitHub browser URLs', () => { + expect(() => parseSkillInstallSource('https://github.com/acme/skills/tree/main/skills/../deploy')).toThrow(/invalid_git_skill_path/); + }); +});