Merge pull request #13725 from esengine/feat/13714-skill-remove

feat(skills): remove a skill from Settings, and never take its whole folder root with it
This commit is contained in:
YHH authored and GitHub committed 2026-10-18 01:56:43 -07:00
commit 4d3c09211b
22 files changed
+727 -31

No files matched your search

+4
View File
@@ -660,6 +660,10 @@ export const ACTIONS: UIAction[] = [
{ id: "skills.update-hidden", kind: "view", target: "none", proof: "interaction" },
{ id: "skills.update-reload", kind: "view", target: "none", proof: "interaction" },
{ id: "skills.update-apply", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "skills.remove-open", kind: "view", target: "entity", proof: "interaction" },
{ id: "skills.remove-cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "skills.remove-reload", kind: "view", target: "none", proof: "interaction" },
{ id: "skills.remove-confirm", kind: "destructive", target: "none", proof: "authority-effect" },
{ id: "subagent-claims.optimistic", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "write-lease.optimistic", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "plan-approval.auto", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -515,6 +515,10 @@ export const EN_KERNEL: Record<string, string> = {
"这个技能不是从本机文件夹复制安装的,无法从源文件夹更新(链接或远程来源请重新安装)": "This skill was not copied from a local folder, so it cannot be updated from one (reinstall a linked or remote skill).",
"找不到来源文件夹,可能被移动或删除了": "The source folder cannot be found; it may have been moved or deleted.",
"自你查看预览后,来源文件夹或已安装的副本又变了。请重新查看再更新": "The source folder or the installed copy changed after you opened the preview. Review it again before updating.",
"这个技能不是你自己的文件,不能在这里卸载(内置技能或由包提供的技能,随包卸载)": "This skill is not a file of yours, so it cannot be removed here (a built-in, or a package's skill, which goes with its package)",
"这个技能自你打开列表后已经变了,没有删除任何内容。请重新读取后再试": "This skill changed after you opened the list, and nothing was deleted. Read the list again and retry",
"卸载技能失败:{detail}": "Could not remove the skill: {detail}",
"其他项目的技能不能在这里卸载": "A skill of another project cannot be removed from here",
"已安装的副本被改过,更新会覆盖这些改动,需要你确认": "The installed copy was edited, and updating overwrites those edits; confirm to continue.",
"这个技能太大,无法更新(文件数或总大小超过上限)": "This skill is too large to update (too many files or too many bytes).",
"更新没有完成,已安装的副本保持原样:{detail}": "The update did not complete and the installed copy was left as it was: {detail}",
@@ -804,6 +804,12 @@ export const EN_SETTINGS: Record<string, string> = {
"源文件夹不见了": "Source folder is gone",
"找不到来源文件夹:{path}": "The source folder cannot be found: {path}",
"从源文件夹更新…": "Update from source folder…",
"卸载…": "Remove…",
"卸载": "Remove",
"卸载中…": "Removing…",
"卸载技能「{name}」?": "Remove the skill \"{name}\"?",
"会从磁盘删除下面这个文件夹及其中所有文件,不能撤销。要再用它,需要重新安装。": "This deletes the folder below and everything in it from disk, and cannot be undone. To use it again you would have to install it again.",
"会从磁盘删除下面这个文件,不能撤销。要再用它,需要重新安装。": "This deletes the file below from disk and cannot be undone. To use it again you would have to install it again.",
"文件太大,这里不显示内容。请到来源文件夹里查看:{path}": "This file is too large to show here. Read it in the source folder: {path}",
"不是文本文件,这里不显示内容。": "Not a text file, so its content is not shown here.",
"(没有可显示的差异)": "(no difference to show)",
+4
View File
@@ -662,6 +662,10 @@ const SAID: Record<string, string> = {
"skills.update_not_local": "这个技能不是从本机文件夹复制安装的,无法从源文件夹更新(链接或远程来源请重新安装)",
"skills.update_source_missing": "找不到来源文件夹,可能被移动或删除了",
"skills.update_plan_stale": "自你查看预览后,来源文件夹或已安装的副本又变了。请重新查看再更新",
"skill.not_removable": "这个技能不是你自己的文件,不能在这里卸载(内置技能或由包提供的技能,随包卸载)",
"skill.changed": "这个技能自你打开列表后已经变了,没有删除任何内容。请重新读取后再试",
"skill.remove_failed": "卸载技能失败:{detail}",
"skill.other_project": "其他项目的技能不能在这里卸载",
"skills.update_needs_confirm": "已安装的副本被改过,更新会覆盖这些改动,需要你确认",
"skills.update_too_large": "这个技能太大,无法更新(文件数或总大小超过上限)",
"skills.update_failed": "更新没有完成,已安装的副本保持原样:{detail}",
+13 -4
View File
@@ -1,5 +1,5 @@
import { download } from "./download";
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan, ScopeLayer, SkillCatalog, SkillEntry, SkillUpdatePlan } from "./port";
import { HttpError, type PluginExport, type PluginInstallRequest, type PluginPackage, type PluginPlan, type ScopeLayer, type SkillCatalog, type SkillEntry, type SkillUpdatePlan } from "./port";
import { MockMarket } from "./mock_market";
// The extension half of the fixture: skills, and the packages that bring them.
@@ -10,7 +10,7 @@ export class MockExtensions extends MockMarket {
private skillList: SkillEntry[] = [
{
name: "review", slashName: "review", description: "复核这一轮改动,给出严重度分级",
scope: "project", path: ".reasonix/skills/review/SKILL.md", subagent: true, enabled: true,
scope: "project", path: ".reasonix/skills/review/SKILL.md", removes: ".reasonix/skills/review", removesFolder: true, subagent: true, enabled: true,
},
{ name: "init", slashName: "init", description: "为这个仓库生成一份项目说明", scope: "builtin", enabled: true },
{
@@ -21,12 +21,12 @@ export class MockExtensions extends MockMarket {
// row the old list could not show at all.
{
name: "release-notes", description: "从提交历史起草发布说明",
scope: "global", path: "~/.reasonix/skills/release-notes/SKILL.md", enabled: false,
scope: "global", path: "~/.reasonix/skills/release-notes/SKILL.md", removes: "~/.reasonix/skills/release-notes", removesFolder: true, enabled: false,
license: "Apache-2.0", compatibility: "需要 git,并能访问网络", metadata: { author: "example-org", version: "1.0" },
},
{
name: "deploy-runbook", slashName: "deploy-runbook", description: "只在你点名时跑的部署清单",
scope: "project", path: ".reasonix/skills/deploy-runbook/SKILL.md", manual: true, enabled: true,
scope: "project", path: ".reasonix/skills/deploy-runbook.md", removes: ".reasonix/skills/deploy-runbook.md", manual: true, enabled: true,
},
];
@@ -77,6 +77,15 @@ export class MockExtensions extends MockMarket {
}
async applySkillUpdate(): Promise<void> {}
async removeSkill(name: string, scope: string, path: string): Promise<void> {
const at = this.skillList.findIndex((x) => x.name === name);
if (at < 0) throw new HttpError(404, "no skill named " + name, { code: "request.not_found" });
const sk = this.skillList[at];
if (sk.scope === "builtin" || sk.plugin || !sk.path) throw new HttpError(409, "not removable", { code: "skill.not_removable" });
if (sk.scope !== scope || sk.path !== path) throw new HttpError(409, "changed", { code: "skill.changed" });
this.skillList.splice(at, 1);
}
async setSkillEnabled(name: string, enabled: boolean, scope: ScopeLayer = "project") {
const sk = this.skillList.find((x) => x.name === name);
if (!sk) return;
+4
View File
@@ -191,6 +191,10 @@ export interface AgentPort {
setSkillEnabled(name: string, enabled: boolean, scope?: ScopeLayer, root?: string): Promise<void>;
planSkillUpdate(name: string): Promise<SkillUpdatePlan>;
applySkillUpdate(name: string, planId: string, overwriteLocal: boolean): Promise<void>;
// Deletes the skill the person was shown, named by the scope and path the list
// drew. A built-in or a package's skill is refused (skill.not_removable), and
// one that changed since the list was read is too (skill.changed).
removeSkill(name: string, scope: string, path: string, root?: string): Promise<void>;
// Drops this project's exception so the skill inherits again.
clearSkillOverride(name: string, root?: string): Promise<void>;
plugins(): Promise<PluginPackage[]>;
+3
View File
@@ -11,6 +11,9 @@ export interface SkillEntry {
scope?: string;
plugin?: string;
path?: string;
// What uninstalling deletes, and whether that is a whole folder; absent when it cannot be removed.
removes?: string;
removesFolder?: boolean;
subagent?: boolean;
readOnly?: boolean;
model?: string;
@@ -38,6 +38,9 @@ export class SseExtensions extends SseLook {
applySkillUpdate(name: string, planId: string, overwriteLocal: boolean) {
return this.post("/skills/" + encodeURIComponent(name) + "/update/apply", { planId, overwriteLocal });
}
removeSkill(name: string, scope: string, path: string, root?: string) {
return this.post("/skills/remove", { name, scope, path, root });
}
clearSkillOverride(name: string, root?: string) {
return this.post("/skills/enabled", { name, clear: true, scope: "project", root });
}
@@ -0,0 +1,45 @@
import { useState } from "react";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { AgentPort, SkillEntry } from "../port/port";
import { HttpError } from "../port/http_error";
const CHANGED = "skill.changed";
// What removing a skill would delete, shown before the person agrees. The
// request carries the scope and path drawn here, so a skill that changed under
// the same name since the list was read is refused, not removed.
export function SkillRemove({ port, sk, onClose, onDone }: { port: AgentPort; sk: SkillEntry; onClose: () => void; onDone: () => void }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [stale, setStale] = useState(false);
const remove = async () => {
setBusy(true);
setError("");
try {
await port.removeSkill(sk.name, sk.scope ?? "", sk.path ?? "", undefined);
onDone();
} catch (e) {
setStale(e instanceof HttpError && e.reason?.code === CHANGED);
setError(reason(e));
setBusy(false);
}
};
return (
<div className="skupd mkt addpkg" data-stage="confirm" aria-busy={busy}>
<div className="find" data-lvl="warn">
<span className="t">{t("卸载技能「{name}」?", { name: sk.name })}</span>
<span className="why">{t(sk.removesFolder ? "会从磁盘删除下面这个文件夹及其中所有文件,不能撤销。要再用它,需要重新安装。" : "会从磁盘删除下面这个文件,不能撤销。要再用它,需要重新安装。")}</span>
<code>{sk.removes}</code>
</div>
<div className="acts">
{stale && <button className="act" data-action="skills.remove-reload" onClick={onDone}>{t("重新读取")}</button>}
<button className="act" data-action="skills.remove-cancel" disabled={busy} onClick={onClose}>{t("取消")}</button>
<button className="act danger" data-action="skills.remove-confirm" disabled={busy} onClick={() => void remove()}>{t(busy ? "卸载中…" : "卸载")}</button>
</div>
{error && <div className="why" role="alert">{error}</div>}
</div>
);
}
@@ -8,6 +8,7 @@ import { SkillSpecInfo } from "./SkillSpecInfo";
import { reason } from "../i18n/kernel";
import { CopyButton } from "./CopyButton";
import { SkillUpdate } from "./SkillUpdate";
import { SkillRemove } from "./SkillRemove";
// One skill, and the two things a reader wants from it: whether the model can
// reach it at all, and which of the layers it was switched at.
@@ -77,6 +78,8 @@ export function SkillRow({
const note = triggerNote(sk, implicit);
const drift = driftNote(sk.source);
const [updating, setUpdating] = useState(false);
const [removing, setRemoving] = useState(false);
const removable = !root && !!sk.removes;
const updatable = sk.source?.mode === "copy" && (sk.source.state === "source_changed" || sk.source.state === "local_edited" || sk.source.state === "diverged");
const local = sk.switchScope === "project";
const command = sk.slashName ? "/" + sk.slashName : "";
@@ -109,6 +112,7 @@ export function SkillRow({
{sk.readOnly && <i className="ro">{t("只读")}</i>}
{drift && <i className="w" data-drift={sk.source?.state} title={drift.says} aria-label={`${drift.text}. ${drift.says}`}>{drift.text}</i>}
{updatable && !updating && <button className="act sm" data-action="skills.update-open" data-target={sk.name} onClick={() => setUpdating(true)}>{t("从源文件夹更新…")}</button>}
{removable && !removing && <button className="act sm" data-action="skills.remove-open" data-target={sk.name} onClick={() => setRemoving(true)}>{t("卸载…")}</button>}
</span>
<span className="sc" title={sk.path}>
{sk.plugin || t(SCOPE[sk.scope ?? ""] ?? "") || sk.scope}
@@ -119,6 +123,7 @@ export function SkillRow({
<SkillSpecInfo sk={sk} />
</div>
{updating && <SkillUpdate port={port} name={sk.name} onClose={() => setUpdating(false)} onDone={() => { setUpdating(false); onDone(); }} />}
{removing && <SkillRemove port={port} sk={sk} onClose={() => setRemoving(false)} onDone={() => { setRemoving(false); onDone(); }} />}
</>
);
}
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { SkillRow } from "./SkillRow";
import { boot, STORAGE } from "../i18n";
import { HttpError } from "../port/port";
import type { AgentPort, SkillEntry } from "../port/port";
beforeEach(() => {
localStorage.setItem(STORAGE, "zh");
boot();
});
afterEach(cleanup);
const mine: SkillEntry = { name: "helper", enabled: true, scope: "global", path: "/home/u/.reasonix/skills/helper/SKILL.md", removes: "/home/u/.reasonix/skills/helper", removesFolder: true, description: "a helper" };
function draw(sk: Partial<SkillEntry> = {}, root = "", removeSkill = vi.fn().mockResolvedValue(undefined)) {
const port = { removeSkill, setSkillEnabled: vi.fn(), clearSkillOverride: vi.fn() } as unknown as AgentPort;
const onDone = vi.fn();
const onFailed = vi.fn();
render(<SkillRow sk={{ ...mine, ...sk }} implicit port={port} root={root} onDone={onDone} onFailed={onFailed} />);
return { removeSkill, onDone, onFailed, user: userEvent.setup() };
}
const open = () => screen.queryByRole("button", { name: "卸载…" });
it("offers removal for a skill of the person's own and for none of the others", () => {
draw();
expect(open()).not.toBeNull();
cleanup();
draw({ scope: "builtin", path: "(builtin)", removes: undefined });
expect(open()).toBeNull();
cleanup();
draw({ plugin: "review-kit", removes: undefined });
expect(open()).toBeNull();
cleanup();
draw({ path: undefined, removes: undefined });
expect(open()).toBeNull();
cleanup();
draw({}, "/w/another-project");
expect(open()).toBeNull();
});
it("says a file, not a folder, when the skill is one file", async () => {
const { user } = draw({ removes: "/home/u/.reasonix/skills/helper.md", removesFolder: false });
await user.click(open()!);
expect(screen.getByText("/home/u/.reasonix/skills/helper.md")).toBeTruthy();
expect(screen.getByText(/删除下面这个文件,/)).toBeTruthy();
});
it("names the place that will be deleted and removes nothing until the person says so", async () => {
const { user, removeSkill } = draw();
await user.click(open()!);
expect(screen.getByText("/home/u/.reasonix/skills/helper")).toBeTruthy();
expect(screen.getByText(/这个文件夹及其中所有文件/)).toBeTruthy();
expect(screen.getByText("卸载技能「helper」?")).toBeTruthy();
expect(removeSkill).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "取消" }));
expect(screen.queryByText("卸载技能「helper」?")).toBeNull();
expect(removeSkill).not.toHaveBeenCalled();
});
it("removes the skill that was shown, by its scope and path, and refreshes the list", async () => {
const { user, removeSkill, onDone } = draw();
await user.click(open()!);
await user.click(screen.getByRole("button", { name: "卸载" }));
expect(removeSkill).toHaveBeenCalledWith("helper", "global", "/home/u/.reasonix/skills/helper/SKILL.md", undefined);
await waitFor(() => expect(onDone).toHaveBeenCalled());
});
it("says so when the list was stale, deletes nothing more, and offers to read it again", async () => {
const removeSkill = vi.fn().mockRejectedValue(new HttpError(409, "changed", { code: "skill.changed" }));
const { user, onDone } = draw({}, "", removeSkill);
await user.click(open()!);
await user.click(screen.getByRole("button", { name: "卸载" }));
expect(await screen.findByRole("alert")).toBeTruthy();
expect(onDone).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "重新读取" }));
expect(onDone).toHaveBeenCalled();
});
+29 -3
View File
@@ -1078,10 +1078,36 @@ func (s *Store) Delete(name string, scope Scope) error {
if sk.Path == "" || sk.Path == "(builtin)" {
return fmt.Errorf("skill %q has no file to delete", name)
}
if filepath.Base(sk.Path) == SkillFile {
return os.RemoveAll(filepath.Dir(sk.Path)) // directory-layout skill: <name>/SKILL.md + siblings
target, folder := s.RemovalTarget(sk)
if folder {
return os.RemoveAll(target)
}
return os.Remove(sk.Path) // legacy flat <name>.md skill
return os.Remove(target)
}
// RemovalTarget is what Delete removes for sk, and whether it is a folder: the
// folder of a directory-layout skill (its SKILL.md and every sibling), or just
// the file of a flat <name>.md skill and of a SKILL.md at the top of a root,
// whose folder is the root that holds every other skill. Delete follows it, so
// what a person is shown is what goes.
func (s *Store) RemovalTarget(sk Skill) (target string, folder bool) {
if filepath.Base(sk.Path) == SkillFile && !s.isRoot(filepath.Dir(sk.Path)) {
return filepath.Dir(sk.Path), true
}
return sk.Path, false
}
func (s *Store) isRoot(dir string) bool {
abs, err := filepath.Abs(dir)
if err != nil {
return false
}
for _, root := range s.roots() {
if r, err := filepath.Abs(root.Dir); err == nil && r == abs {
return true
}
}
return false
}
func (s *Store) globalSkillsRoot() string {
+82
View File
@@ -459,6 +459,88 @@ func TestDeleteRemovesDirectoryLayoutSkill(t *testing.T) {
}
}
// A SKILL.md at the top of a skills root is listed as a skill, and its folder is
// the root: removing the folder would take every other skill with it.
func TestDeleteOfARootLevelSkillLeavesTheOtherSkills(t *testing.T) {
home := testenv.TempDir(t)
root := filepath.Join(home, ".reasonix", SkillsDirname)
other := filepath.Join(root, "other")
if err := os.MkdirAll(other, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(other, SkillFile), []byte("---\ndescription: o\n---\nbody"), 0o644); err != nil {
t.Fatal(err)
}
top := filepath.Join(root, SkillFile)
if err := os.WriteFile(top, []byte("---\nname: top\ndescription: t\n---\nbody"), 0o644); err != nil {
t.Fatal(err)
}
st := New(Options{HomeDir: home, DisableBuiltins: true})
if err := st.Delete("top", ScopeGlobal); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := os.Stat(top); !os.IsNotExist(err) {
t.Fatalf("the root-level skill file should be gone, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(other, SkillFile)); err != nil {
t.Fatalf("a sibling skill was removed with it: %v", err)
}
if _, ok := st.Read("other"); !ok {
t.Fatal("the other skill must still be readable")
}
}
// What a removal would delete is stated once, by the same rule Delete follows,
// so a person shown it is shown exactly what goes: a folder for a directory-layout
// skill, a single file for a flat <name>.md skill, and only SKILL.md for one that
// sits at the top of a skills root.
func TestRemovalTargetIsExactlyWhatDeleteRemoves(t *testing.T) {
home := testenv.TempDir(t)
root := filepath.Join(home, ".reasonix", SkillsDirname)
write := func(rel, body string) string {
p := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return p
}
write("folder-skill/SKILL.md", "---\ndescription: f\n---\nbody")
write("folder-skill/scripts/run.sh", "echo hi")
write("flat-skill.md", "---\ndescription: f\n---\nbody")
write(SkillFile, "---\nname: top\ndescription: t\n---\nbody")
st := New(Options{HomeDir: home, DisableBuiltins: true})
for _, tc := range []struct {
name string
target string
folder bool
}{
{"folder-skill", filepath.Join(root, "folder-skill"), true},
{"flat-skill", filepath.Join(root, "flat-skill.md"), false},
{"top", filepath.Join(root, SkillFile), false},
} {
sk, ok := st.Read(tc.name)
if !ok {
t.Fatalf("%s is not listed", tc.name)
}
target, folder := st.RemovalTarget(sk)
if target != tc.target || folder != tc.folder {
t.Fatalf("%s: RemovalTarget = %q folder=%v, want %q folder=%v", tc.name, target, folder, tc.target, tc.folder)
}
if err := st.Delete(tc.name, ScopeGlobal); err != nil {
t.Fatalf("Delete(%s): %v", tc.name, err)
}
if _, err := os.Lstat(target); !os.IsNotExist(err) {
t.Fatalf("%s: the shown target %q is still there: %v", tc.name, target, err)
}
}
if _, err := os.Stat(filepath.Join(root, "folder-skill", "scripts", "run.sh")); !os.IsNotExist(err) {
t.Fatal("the folder was shown as the target, so its sibling files go with it")
}
}
func TestDeleteRefusesBuiltin(t *testing.T) {
st := New(Options{HomeDir: testenv.TempDir(t)})
if err := st.Delete("explore", ScopeBuiltin); err == nil {
+31 -24
View File
@@ -67,17 +67,21 @@ func (s *Server) slash(w http.ResponseWriter, r *http.Request) {
// slash list answers "what can I type"; this answers "what may run", which is
// the larger set: a skill with no slash name still fires on model discovery.
type skillEntry struct {
Name string `json:"name"`
SlashName string `json:"slashName,omitempty"`
Description string `json:"description,omitempty"`
Scope string `json:"scope,omitempty"`
Plugin string `json:"plugin,omitempty"`
Path string `json:"path,omitempty"`
Subagent bool `json:"subagent,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
Model string `json:"model,omitempty"`
Effort string `json:"effort,omitempty"`
AllowedURI []string `json:"allowedTools,omitempty"`
Name string `json:"name"`
SlashName string `json:"slashName,omitempty"`
Description string `json:"description,omitempty"`
Scope string `json:"scope,omitempty"`
Plugin string `json:"plugin,omitempty"`
Path string `json:"path,omitempty"`
// Removes is what uninstalling would delete from disk, and RemovesFolder says
// whether that is a whole folder; empty when the skill cannot be removed.
Removes string `json:"removes,omitempty"`
RemovesFolder bool `json:"removesFolder,omitempty"`
Subagent bool `json:"subagent,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
Model string `json:"model,omitempty"`
Effort string `json:"effort,omitempty"`
AllowedURI []string `json:"allowedTools,omitempty"`
// License, Compatibility and Metadata are the specification's descriptive
// fields as the skill wrote them.
License string `json:"license,omitempty"`
@@ -123,20 +127,23 @@ func (s *Server) skills(w http.ResponseWriter, r *http.Request) {
if sk.DisableUserInvocation {
slashName = ""
}
removes, folder, _ := ctl.SkillRemoval(sk)
entries = append(entries, skillEntry{
SwitchScope: switchScope,
Name: sk.Name,
SlashName: slashName,
Description: sk.Description,
Scope: string(sk.Scope),
Plugin: sk.Plugin,
Path: sk.Path,
Subagent: sk.RunAs == skill.RunSubagent,
ReadOnly: sk.ReadOnly,
Model: sk.Model,
Effort: sk.Effort,
AllowedURI: sk.AllowedTools,
License: sk.License, Compatibility: sk.Compatibility, Metadata: sk.Metadata,
Removes: removes,
RemovesFolder: folder,
SwitchScope: switchScope,
Name: sk.Name,
SlashName: slashName,
Description: sk.Description,
Scope: string(sk.Scope),
Plugin: sk.Plugin,
Path: sk.Path,
Subagent: sk.RunAs == skill.RunSubagent,
ReadOnly: sk.ReadOnly,
Model: sk.Model,
Effort: sk.Effort,
AllowedURI: sk.AllowedTools,
License: sk.License, Compatibility: sk.Compatibility, Metadata: sk.Metadata,
Manual: sk.DisableModelInvocation || strings.EqualFold(strings.TrimSpace(sk.Invocation), "manual"),
Enabled: enabled(sk.Name),
Source: sources.of(sk.Name, string(sk.Scope), sk.Path),
+1
View File
@@ -95,6 +95,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /hooks/dry-run", s.dryRunHook)
mux.HandleFunc("GET /skills", s.skills)
mux.HandleFunc("POST /skills/enabled", s.skillEnabled)
s.registerSkillRemoveRoutes(mux)
s.registerSkillUpdateRoutes(mux)
mux.HandleFunc("GET /slash", s.slash)
mux.HandleFunc("GET /complete", s.complete)
+68
View File
@@ -0,0 +1,68 @@
package serve
import (
"encoding/json"
"errors"
"net/http"
"strings"
"reasonix/internal/ext/skill"
"reasonix/internal/session/control"
)
func (s *Server) registerSkillRemoveRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /skills/remove", s.skillRemove)
}
// skillRemove deletes the skill the person was shown. The request names the
// scope and the path the list drew, so a skill that has since changed under the
// same name is refused instead of removed. Deleting files on this machine is the
// window's alone: a paired device reaches the pane's routes, so it is refused
// here. Another project's list is read, not edited, from this pane.
func (s *Server) skillRemove(w http.ResponseWriter, r *http.Request) {
if !hostReach(r) {
refuse(w, http.StatusNotFound, codeDeviceHostOnly, "a paired device cannot use this", nil)
return
}
var body struct {
Name string `json:"name"`
Scope string `json:"scope"`
Path string `json:"path"`
Root string `json:"root"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
badBody(w)
return
}
name, scope, path := strings.TrimSpace(body.Name), strings.TrimSpace(body.Scope), strings.TrimSpace(body.Path)
for field, v := range map[string]string{"name": name, "scope": scope, "path": path} {
if v == "" {
missingField(w, field)
return
}
}
root, other, ok := s.resolveRoot(body.Root)
if !ok {
unknownProject(w, root)
return
}
if other {
refuse(w, http.StatusConflict, "skill.other_project", "a skill of another project is not removed from this pane", map[string]any{"root": root})
return
}
switch err := s.ctl().RemoveSkill(name, skill.Scope(scope), path); {
case err == nil:
w.WriteHeader(http.StatusNoContent)
case errors.Is(err, control.ErrSkillNotRemovable):
refuse(w, http.StatusConflict, "skill.not_removable", err.Error(), nil)
case errors.Is(err, control.ErrSkillChanged):
refuse(w, http.StatusConflict, "skill.changed", err.Error(), nil)
default:
var unknown *skill.NotFoundError
if errors.As(err, &unknown) {
notFound(w, "skill", unknown.Name)
return
}
refuse(w, http.StatusInternalServerError, "skill.remove_failed", err.Error(), map[string]any{"detail": err.Error()})
}
}
@@ -0,0 +1,162 @@
package serve
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/ext/skill"
"reasonix/internal/session/control"
)
type skillRemover struct {
control.SessionAPI
err error
name string
scope skill.Scope
path string
calls int
}
func (c *skillRemover) WorkspaceRoot() string { return "/work/app" }
func (c *skillRemover) RemoveSkill(name string, scope skill.Scope, path string) error {
c.calls++
c.name, c.scope, c.path = name, scope, path
return c.err
}
func removeSkillRequest(src *skillRemover, body string) *httptest.ResponseRecorder {
return removeSkillFrom(src, body, false)
}
func removeSkillFrom(src *skillRemover, body string, device bool) *httptest.ResponseRecorder {
mux := http.NewServeMux()
(&Server{ctrl: src}).registerSkillRemoveRoutes(mux)
req := httptest.NewRequest(http.MethodPost, "/skills/remove", strings.NewReader(body))
if device {
req = req.WithContext(withDeviceReach(req.Context(), "phone-1", 1))
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
func TestAPairedDeviceCannotRemoveASkill(t *testing.T) {
src := &skillRemover{}
rec := removeSkillFrom(src, `{"name":"helper","scope":"global","path":"/p/SKILL.md"}`, true)
var reason Reason
_ = json.Unmarshal(rec.Body.Bytes(), &reason)
if rec.Code != http.StatusNotFound || reason.Code != codeDeviceHostOnly || src.calls != 0 {
t.Fatalf("a device: status %d code %q calls %d, want 404 %s and nothing removed", rec.Code, reason.Code, src.calls, codeDeviceHostOnly)
}
}
func TestRemovingASkillPassesTheShownScopeAndPathToTheController(t *testing.T) {
src := &skillRemover{}
rec := removeSkillRequest(src, `{"name":"helper","scope":"global","path":"/home/u/.reasonix/skills/helper/SKILL.md"}`)
if rec.Code != http.StatusNoContent {
t.Fatalf("status %d body %s, want 204", rec.Code, rec.Body.String())
}
if src.name != "helper" || src.scope != skill.ScopeGlobal || src.path != "/home/u/.reasonix/skills/helper/SKILL.md" {
t.Fatalf("controller got %q %q %q", src.name, src.scope, src.path)
}
}
func TestRemovingASkillNeedsTheNameAndThePathItWasShown(t *testing.T) {
for _, body := range []string{`{}`, `{"name":"helper"}`, `{"name":"helper","scope":"global"}`, `{"path":"/x"}`, `not json`} {
src := &skillRemover{}
rec := removeSkillRequest(src, body)
if rec.Code != http.StatusBadRequest || src.calls != 0 {
t.Fatalf("%q: status %d calls %d, want 400 and no removal", body, rec.Code, src.calls)
}
}
}
func TestSkillRemovalRefusalsKeepTheirIdentity(t *testing.T) {
for _, tc := range []struct {
err error
status int
code string
}{
{control.ErrSkillNotRemovable, http.StatusConflict, "skill.not_removable"},
{control.ErrSkillChanged, http.StatusConflict, "skill.changed"},
{&skill.NotFoundError{Name: "helper"}, http.StatusNotFound, "request.not_found"},
{errors.New("disk on fire"), http.StatusInternalServerError, "skill.remove_failed"},
} {
rec := removeSkillRequest(&skillRemover{err: tc.err}, `{"name":"helper","scope":"global","path":"/p/SKILL.md"}`)
var reason Reason
_ = json.Unmarshal(rec.Body.Bytes(), &reason)
if rec.Code != tc.status || reason.Code != tc.code {
t.Fatalf("%v: status %d code %q, want %d %s", tc.err, rec.Code, reason.Code, tc.status, tc.code)
}
}
}
func TestEachListedSkillSaysExactlyWhatRemovingItDeletes(t *testing.T) {
home := testenv.TempDir(t)
st := skill.New(skill.Options{HomeDir: home, DisableBuiltins: true})
ctrl := control.New(control.Options{AllSkillStore: st, SkillStore: st})
defer ctrl.Close()
folderPath, err := ctrl.CreateSkill("folder-skill", skill.ScopeGlobal, "---\ndescription: in a folder\n---\nbody")
if err != nil {
t.Fatal(err)
}
root := filepath.Dir(filepath.Dir(folderPath))
flat := filepath.Join(root, "flat-skill.md")
if err := os.WriteFile(flat, []byte("---\ndescription: one file\n---\nbody"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "SKILL.md"), []byte("---\nname: top-skill\ndescription: at the root\n---\nbody"), 0o644); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(operatorHandler(New(ctrl, NewBroadcaster(), config.ServeConfig{})))
defer srv.Close()
resp, err := http.Get(srv.URL + "/skills")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var got struct {
Skills []skillEntry `json:"skills"`
}
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatal(err)
}
by := map[string]skillEntry{}
for _, e := range got.Skills {
by[e.Name] = e
}
want := map[string]struct {
removes string
folder bool
}{
"folder-skill": {filepath.Dir(folderPath), true},
"flat-skill": {flat, false},
"top-skill": {filepath.Join(root, "SKILL.md"), false},
}
for name, w := range want {
e, ok := by[name]
if !ok {
t.Fatalf("%s is not listed: %+v", name, got.Skills)
}
if e.Removes != w.removes || e.RemovesFolder != w.folder {
t.Errorf("%s: removes %q folder=%v, want %q folder=%v", name, e.Removes, e.RemovesFolder, w.removes, w.folder)
}
}
if err := ctrl.RemoveSkill("top-skill", skill.ScopeGlobal, by["top-skill"].Path); err != nil {
t.Fatal(err)
}
for _, p := range []string{flat, folderPath} {
if _, err := os.Stat(p); err != nil {
t.Errorf("removing the root-level skill took %s with it: %v", p, err)
}
}
}
+6
View File
@@ -226,6 +226,12 @@ type Skills interface {
CreateSkill(name string, scope skill.Scope, content string) (string, error)
UpdateSkill(name string, scope skill.Scope, content string) error
DeleteSkill(name string, scope skill.Scope) error
// RemoveSkill deletes the skill the person was shown, named by scope and path;
// a package's skill or a built-in is refused (ErrSkillNotRemovable).
RemoveSkill(name string, scope skill.Scope, path string) error
// SkillRemoval says what RemoveSkill would delete for sk and whether it is a
// folder; ok is false for a skill that cannot be removed.
SkillRemoval(sk skill.Skill) (target string, folder, ok bool)
}
// Hooks covers the session's lifecycle hooks: the live runner, what is
+50
View File
@@ -1,7 +1,9 @@
package control
import (
"errors"
"fmt"
"path/filepath"
"sync/atomic"
"reasonix/internal/contract/config"
@@ -193,6 +195,54 @@ func (c *Controller) DeleteSkill(name string, scope skill.Scope) error {
return w.Delete(name, scope)
}
var (
// ErrSkillNotRemovable is a skill with no folder of the user's own to delete:
// a built-in, or one a package provides, which goes when the package does.
ErrSkillNotRemovable = errors.New("this skill is not the user's own folder to remove")
// ErrSkillChanged is a removal aimed at a skill other than the one the list
// showed: the same name at another scope or path.
ErrSkillChanged = errors.New("the skill is no longer the one that was shown")
)
// RemoveSkill deletes the skill the person was shown, named by its scope and
// path. A built-in or a package's skill is refused with its own identity, and a
// request whose scope or path no longer matches what the name resolves to is
// refused rather than aimed at whatever now answers to that name.
func (c *Controller) RemoveSkill(name string, scope skill.Scope, path string) error {
w := c.skills.writer()
if w == nil {
return fmt.Errorf("no writable skill store in this session")
}
sk, ok := w.Read(name)
if !ok {
return &skill.NotFoundError{Name: name}
}
if !skillRemovable(sk) {
return ErrSkillNotRemovable
}
if sk.Scope != scope || filepath.Clean(sk.Path) != filepath.Clean(path) {
return ErrSkillChanged
}
return w.Delete(name, scope)
}
// skillRemovable is the one rule for which skills have a place of the user's own
// to delete: not a built-in, not a package's, and backed by a file.
func skillRemovable(sk skill.Skill) bool {
return sk.Scope != skill.ScopeBuiltin && sk.Plugin == "" && sk.Path != "" && sk.Path != "(builtin)"
}
// SkillRemoval says what removing sk would delete and whether that is a folder,
// by the rule RemoveSkill follows; ok is false for a skill that cannot be removed.
func (c *Controller) SkillRemoval(sk skill.Skill) (target string, folder, ok bool) {
w := c.skills.writer()
if w == nil || !skillRemovable(sk) {
return "", false, false
}
target, folder = w.RemovalTarget(sk)
return target, folder, true
}
// Skills scans the live Store, so a skill installed this session is listed.
func (c *Controller) Skills() []skill.Skill {
return c.skills.list()
@@ -0,0 +1,121 @@
package control
import (
"errors"
"os"
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/ext/skill"
)
func removeFixture(t *testing.T) (*Controller, *skill.Store, string) {
t.Helper()
home := testenv.TempDir(t)
st := skill.New(skill.Options{HomeDir: home, DisableBuiltins: true})
c := New(Options{AllSkillStore: st, SkillStore: st})
path, err := c.CreateSkill("helper", skill.ScopeGlobal, "---\ndescription: a helper\n---\nbe helpful")
if err != nil {
t.Fatal(err)
}
return c, st, path
}
func TestRemoveSkillDeletesTheFolderThePersonWasShown(t *testing.T) {
c, st, path := removeFixture(t)
if err := c.RemoveSkill("helper", skill.ScopeGlobal, path); err != nil {
t.Fatalf("RemoveSkill: %v", err)
}
if _, ok := st.Read("helper"); ok {
t.Fatal("the skill is still listed")
}
if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) {
t.Fatalf("its folder should be gone: %v", err)
}
}
func TestRemoveSkillRefusesWhenTheListWasStale(t *testing.T) {
c, st, path := removeFixture(t)
for _, tc := range []struct {
name string
scope skill.Scope
path string
}{
{"another path of the same name", skill.ScopeGlobal, filepath.Join(filepath.Dir(path), "..", "elsewhere", "SKILL.md")},
{"another scope", skill.ScopeProject, path},
} {
if err := c.RemoveSkill("helper", tc.scope, tc.path); !errors.Is(err, ErrSkillChanged) {
t.Fatalf("%s = %v, want ErrSkillChanged", tc.name, err)
}
}
if _, ok := st.Read("helper"); !ok {
t.Fatal("a refused removal deleted the skill")
}
}
func TestRemoveSkillRefusesWhatIsNotTheUsersToDelete(t *testing.T) {
home := testenv.TempDir(t)
pluginRoot := filepath.Join(testenv.TempDir(t), "pkg-skills")
body := "---\ndescription: from a package\n---\nbody"
if err := os.MkdirAll(filepath.Join(pluginRoot, "pkgskill"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginRoot, "pkgskill", skill.SkillFile), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
st := skill.New(skill.Options{HomeDir: home, CustomPaths: []string{pluginRoot}, PluginPaths: map[string][]string{pluginRoot: {"the-package"}}})
c := New(Options{AllSkillStore: st, SkillStore: st})
sk, ok := st.Read("pkgskill")
if !ok || sk.Plugin == "" {
t.Fatalf("fixture: package skill = %+v ok=%v", sk, ok)
}
if err := c.RemoveSkill("pkgskill", sk.Scope, sk.Path); !errors.Is(err, ErrSkillNotRemovable) {
t.Fatalf("a package's skill = %v, want ErrSkillNotRemovable", err)
}
if _, err := os.Stat(sk.Path); err != nil {
t.Fatalf("the package's file was touched: %v", err)
}
var builtin skill.Skill
for _, b := range skill.New(skill.Options{HomeDir: home}).List() {
if b.Scope == skill.ScopeBuiltin {
builtin = b
break
}
}
live := skill.New(skill.Options{HomeDir: home})
cb := New(Options{AllSkillStore: live, SkillStore: live})
if err := cb.RemoveSkill(builtin.Name, skill.ScopeBuiltin, builtin.Path); !errors.Is(err, ErrSkillNotRemovable) {
t.Fatalf("a built-in = %v, want ErrSkillNotRemovable", err)
}
}
func TestRemoveSkillSaysWhenThereIsNoSuchSkill(t *testing.T) {
c, _, _ := removeFixture(t)
var unknown *skill.NotFoundError
if err := c.RemoveSkill("nope", skill.ScopeGlobal, "/x"); !errors.As(err, &unknown) {
t.Fatalf("unknown skill = %v, want *skill.NotFoundError", err)
}
}
func TestSkillRemovalSaysWhatWouldBeDeleted(t *testing.T) {
c, st, path := removeFixture(t)
sk, _ := st.Read("helper")
target, folder, ok := c.SkillRemoval(sk)
if !ok || !folder || target != filepath.Dir(path) {
t.Fatalf("SkillRemoval = %q folder=%v ok=%v, want the skill's folder %q", target, folder, ok, filepath.Dir(path))
}
builtin := skill.Skill{Name: "explore", Scope: skill.ScopeBuiltin, Path: "(builtin)"}
if _, _, ok := c.SkillRemoval(builtin); ok {
t.Fatal("a built-in reported a place to delete")
}
if _, _, ok := c.SkillRemoval(skill.Skill{Name: "p", Scope: skill.ScopeGlobal, Plugin: "pkg", Path: "/x/SKILL.md"}); ok {
t.Fatal("a package's skill reported a place to delete")
}
if err := c.RemoveSkill("helper", skill.ScopeGlobal, path); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatalf("the shown target %q is still there: %v", target, err)
}
}
@@ -0,0 +1,2 @@
- 新增:设置 → 扩展里,你自己和项目文件夹里的技能多了「卸载…」,确认框写明要删除的位置;内置技能和由包提供的技能不显示。关联 #13714。
- Added: Settings > Extensions can remove a skill of your own or the project's, after a confirmation naming the place to be deleted; built-in and package skills do not offer it. Refs #13714.
@@ -0,0 +1,2 @@
- 修复:技能目录顶层放了 SKILL.md 时,卸载它会把整个技能目录连同其他技能一起删掉;现在只删这一个文件。关联 #13714。
- Fixed: Removing a skill whose SKILL.md sits at the top of a skills folder deleted the whole folder with every other skill; it now deletes only that file. Refs #13714.