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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 的主窗口:
Expand Down
155 changes: 155 additions & 0 deletions docs/setup/skills.md
Original file line number Diff line number Diff line change
@@ -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>'` 修改:

```json
{
"skills": {
"include": ["skill:deploy-runbook"]
}
}
```

字段含义:

- `include`: priority skill 列表,只支持 `skill:<name>`。这些 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 <appId|name|index> --cwd <repo>
botmux skills delivery --bot <appId|name|index> --cwd <repo>
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 <appId|name|index> --cwd <repo>
botmux skills delivery --bot <appId|name|index> --cwd <repo>
```

如果某个 bot 没有配置 custom skills,`resolve` 会显示 `skills: default`,表示新能力没有接管或改变底层 CLI 的默认 skill 加载行为。
13 changes: 13 additions & 0 deletions src/adapters/backend/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -383,6 +395,7 @@ export function prepareSandbox(opts: {
hideDirs,
hideFiles,
authReal,
readonlyRoots,
net: true,
};
const args = buildSandboxArgs(plan);
Expand Down
4 changes: 3 additions & 1 deletion src/adapters/cli/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/adapters/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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 <cliId>`。undefined = 不通过 hook 接管 askUserQuestion。 */
readonly hookInstall?: {
Expand Down
33 changes: 33 additions & 0 deletions src/bot-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 做,这里只挡明显垃圾。
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, unknown>;
const out: BotSkillPolicy = {};
const include = readDirectSkillSelectors(r.include);
if (include) out.include = include;
return Object.keys(out).length > 0 ? out : undefined;
}
16 changes: 16 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cliId>` — hook 客户端,stdin 读 payload,stdout 写 directive
const cliId = process.argv[3] ?? '';
Expand Down
Loading