mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-08 01:48:26 -10:00
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:
22 files changed
+727
-31
No files matched your search
@@ -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)",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user