写一个 skill

一个 skill 就是一个目录:skill.json + index.ts。核心只有一个函数 plan()。

15 分钟写出第一个 skill

整套流程有自动化:scripts/onboarding-test.ts 会把 tarball 打进临时 prefix,然后只照这份清单执行一遍。文档和产品一旦对不上,这个测试就会红。

# 0. 前置:Node 22+ 在 PATH 里(shim 要调用 node)
deskshell --version

# 1. 生成骨架 —— 落在 ~/.deskshell/skills/<name>,不会往你的工作目录里塞仓库结构
deskshell new hello-report
# → 生成 skill.json + index.ts,并打印下一步

# 2. 只改一处:打开 index.ts 改 plan()
#    (模板本身已经把文件列表写成 report.md)

# 3. 能被列出来吗?
deskshell list | Select-String hello-report

# 4. 预览:什么都不碰
deskshell run hello-report --root .\some-folder --mode preview

# 5. 执行,再撤销 —— 两步走完整套信任模型
deskshell run hello-report --root .\some-folder --mode apply
deskshell undo --last --root .\some-folder

预期预览长这样:

汇总:...
步骤:
  write:report  [ok]

三个真正会绊住人的坑(都已修,且被测试钉住):

| 现象 | 原因 | 处理 | | --- | --- | --- | | '"node"' 不是内部或外部命令 | 装了 Node 但不在 PATH | 把安装目录加进 PATH,重开终端 | | deskshell new 在你文件旁边建了 packages/skills | 老版本行为 | 升级;现在写到用户技能目录,给本仓库贡献才用 --in-repo | | --last needs a positive integer | 老版本必须带数字 | 现在 deskshell undo --last 就是"撤最近 1 个",--last 3 照旧 |

最小模板

import type { Action, Skill } from "../../core/src/types.ts";

export const skill: Skill = {
  manifest: {
    name: "hello-move",
    version: "0.1.0",
    description: "把 a.txt 移到 docs/ 下",
    entry: "./index.ts",
    capabilities: [
      { id: "fs.write", scope: "{{root}}", reason: "在 root 内移动文件" },
    ],
  },
  async plan({ root }) {
    return [
      {
        id: "mkdir:docs",
        kind: "fs.mkdir",
        path: `${root}/docs`,
        risk: "low",
        post: [{ kind: "path.exists", path: `${root}/docs` }],
        undo: { kind: "fs.rmdir", path: `${root}/docs` },
      },
      {
        id: "move:a",
        kind: "fs.move",
        from: `${root}/a.txt`,
        to: `${root}/docs/a.txt`,
        risk: "low",
        pre: [{ kind: "path.exists", path: `${root}/a.txt` }],
        post: [{ kind: "path.exists", path: `${root}/docs/a.txt` }],
        undo: { kind: "fs.move", from: `${root}/docs/a.txt`, to: `${root}/a.txt` },
      },
    ];
  },
};

三条编写准则

准则一:为每个动作提供 post 断言。 检查目标状态是否确实成立。协议 v0 为兼容现有技能,仍将 post 设为可选;内核校验已提供的断言,但不强制每步都有断言。

准则二:为可逆动作提供 undo。 缺失/null undo 或 irreversible: true 都需要在执行前显式确认。已确认执行的不可逆动作无法恢复,不能用占位撤销动作伪装。可撤销的删除需求请用"移入回收站目录"表达;fs.delete 缺少撤销动作时,即使确认也会被拒绝。

规则三:不要碰系统 API。 skill 里不写 fs.writeFileSync、不写 child_process、不写 UIA。你只能产出 Action,由内核去执行。

需要限定平台怎么办

在 skill.json 里声明:

{ "platforms": ["win32"] }

缺省表示全平台。声明之后,deskshell list 会在其它平台标注"仅 win32",用户一眼能看出这个 skill 在 Mac 上跑不了。

需要读屏幕怎么办

plan(input, ctx) 的第二个参数是上下文,里面有 ctx.driver:

async plan({ root }, ctx) {
  const tree = await ctx.driver!.tree({ titleRegex: "Edge" }, 8);
  const tabs = walk(tree).filter((n) => n.controlType === "TabItem");
  // 根据观察结果生成 Action
}

观察是只读的,不产生副作用;所有副作用必须回到 Action 里。

常见错误

只写 pre 不写 post:失败会在下游爆炸,且无法定位是哪一步坏的。

undo 缺失或为 null:执行前需要显式确认不可逆动作,预览会标明不可撤销;fs.delete 缺少撤销动作仍被禁止。

路径用相对路径:一律用 path.join(input.root, ...),并确保不越界。

在 plan 里做副作用:plan 会被调用两次(预览一次、执行一次),任何写入都会翻倍。

调试

node apps/cli-demo/main.ts --mode preview   # 只看计划,不动手

预览会打印每一步的 risk 与 undoable,这是最快的自检方式。