mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
从官方 Git 仓库导入 chandra447/pi-hermes-memory 在 tag v0.9.10 (df624056a2e8c68bc89ba0a81e1c120856ec9665,2026-10-02)的源码快照。 本次仅导入源码,尚未接入组合包:package.json、加载顺序、依赖与权限策略 均未改动。 - 原样导入 src/、tests/、docs/、scripts/、README、CHANGELOG、PLAN、 AGENTS 与 LICENSE;该快照共 156 个上游文件。 - 剔除嵌套 .git、上游 .github 工作流、.ralph/、.claude/、node_modules、 覆盖率与构建产物。 - 新增中文 UPSTREAM.md,记录来源,以及启用前必须完成的待决事项: 在 pi-compaction 之前注册加载顺序、better-sqlite3 版本与根 allowScripts 对齐、strip-ansi 解析确认。 - 说明选择 tag 而非 main 的原因:导入时上游 main 已领先至 24fcd8ad26fc27badb8d7dbaf8034645de07b893(2026-10-11),选 tag 是为 对应一个已发布、可复现的版本。 尚未接线。启用前还需反转 tests/extension-load.test.ts 中"不得加载 Hermes"的断言,并同步 pi-tool-search 分组、权限策略与文档。 验证:快照 156 个上游文件全部导入,目录内仅多出 UPSTREAM.md 一份; 已确认与上游 main 内容存在差异,证明取的是 tag 而非 main。
828 lines
28 KiB
TypeScript
828 lines
28 KiB
TypeScript
import { describe, it } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
buildSkillRows,
|
|
buildUnifiedSkillRows,
|
|
collectLoadedSkillsFromCommands,
|
|
confirmDeleteSelectedSkills,
|
|
deleteSelectedSkills,
|
|
filterSkillRows,
|
|
moveSelectedSkills,
|
|
registerSkillsCommand,
|
|
SkillsManagerModal,
|
|
type SkillBatchActionResult,
|
|
} from "../../src/handlers/skills-command.js";
|
|
import type { SkillIndex, SkillResult } from "../../src/types.js";
|
|
|
|
const SAMPLE_SKILLS: SkillIndex[] = [
|
|
{
|
|
skillId: "global:debug-typescript-errors",
|
|
scope: "global",
|
|
fileName: "SKILL.md",
|
|
path: "/tmp/global/debug-typescript-errors/SKILL.md",
|
|
name: "debug-typescript-errors",
|
|
displayName: "Debug TypeScript Errors",
|
|
description: "Trace compiler issues step by step",
|
|
created: "2026-05-19",
|
|
updated: "2026-05-21",
|
|
},
|
|
{
|
|
skillId: "project:demo-project:deploy-checklist",
|
|
scope: "project",
|
|
fileName: "SKILL.md",
|
|
path: "/tmp/project/deploy-checklist/SKILL.md",
|
|
projectName: "demo-project",
|
|
name: "deploy-checklist",
|
|
displayName: "Deploy Checklist",
|
|
description: "Project release checklist",
|
|
created: "2026-05-18",
|
|
updated: "2026-05-20",
|
|
},
|
|
];
|
|
|
|
const SORT_MODE_SKILLS: SkillIndex[] = [
|
|
{
|
|
skillId: "global:zebra-runbook",
|
|
scope: "global",
|
|
fileName: "SKILL.md",
|
|
path: "/tmp/global/zebra-runbook/SKILL.md",
|
|
name: "zebra-runbook",
|
|
displayName: "Zebra Runbook",
|
|
description: "Older creation date, newer update date",
|
|
created: "2026-05-18",
|
|
updated: "2026-05-21",
|
|
},
|
|
{
|
|
skillId: "project:demo-project:alpha-checklist",
|
|
scope: "project",
|
|
fileName: "SKILL.md",
|
|
path: "/tmp/project/alpha-checklist/SKILL.md",
|
|
projectName: "demo-project",
|
|
name: "alpha-checklist",
|
|
displayName: "Alpha Checklist",
|
|
description: "Newer creation date, older update date",
|
|
created: "2026-05-22",
|
|
updated: "2026-05-20",
|
|
},
|
|
];
|
|
|
|
const LOADED_SKILL_COMMANDS = [
|
|
{
|
|
name: "skill:debug-typescript-errors",
|
|
description: "Trace compiler issues step by step",
|
|
source: "skill",
|
|
sourceInfo: { path: "/tmp/global/debug-typescript-errors/SKILL.md" },
|
|
},
|
|
{
|
|
name: "skill:langgraph-fundamentals",
|
|
description: "LangGraph patterns",
|
|
source: "skill",
|
|
sourceInfo: { path: "/Users/demo/.agents/skills/langgraph-fundamentals/SKILL.md" },
|
|
},
|
|
{
|
|
name: "memory-skills",
|
|
description: "not a skill command",
|
|
source: "extension",
|
|
sourceInfo: { path: "/tmp/ignore" },
|
|
},
|
|
] as const;
|
|
|
|
describe("skills command helpers", () => {
|
|
it("buildSkillRows preserves selected ids", () => {
|
|
const rows = buildSkillRows(SAMPLE_SKILLS, new Set(["project:demo-project:deploy-checklist"]));
|
|
|
|
assert.strictEqual(rows.length, 2);
|
|
assert.strictEqual(rows[0].selected, false);
|
|
assert.strictEqual(rows[1].selected, true);
|
|
assert.strictEqual(rows[0].searchText.includes("Debug TypeScript Errors"), true);
|
|
});
|
|
|
|
it("filterSkillRows uses fuzzy skill-name matching", () => {
|
|
const rows = buildSkillRows(SAMPLE_SKILLS);
|
|
const filtered = filterSkillRows(rows, "dbg ts err");
|
|
|
|
assert.strictEqual(filtered.length, 1);
|
|
assert.strictEqual(filtered[0].skillId, "global:debug-typescript-errors");
|
|
});
|
|
|
|
it("collectLoadedSkillsFromCommands returns loaded runtime skills only", () => {
|
|
const loaded = collectLoadedSkillsFromCommands(LOADED_SKILL_COMMANDS as any);
|
|
|
|
assert.strictEqual(loaded.length, 2);
|
|
assert.strictEqual(loaded[0]?.name, "debug-typescript-errors");
|
|
assert.strictEqual(loaded[1]?.name, "langgraph-fundamentals");
|
|
});
|
|
|
|
it("collectLoadedSkillsFromCommands ignores malformed and pathless commands", () => {
|
|
const loaded = collectLoadedSkillsFromCommands([
|
|
{ source: "skill", name: "skill:valid", sourceInfo: { path: "/tmp/valid/SKILL.md" } },
|
|
{ source: "skill", name: "skill:no-path", sourceInfo: {} },
|
|
{ source: "skill", name: "skill:blank-path", sourceInfo: { path: " " } },
|
|
{ source: "skill", name: " ", sourceInfo: { path: "/tmp/blank-name/SKILL.md" } },
|
|
{ source: "skill", sourceInfo: { path: "/tmp/missing-name/SKILL.md" } },
|
|
{ source: "skill", name: 123, sourceInfo: { path: "/tmp/invalid-name/SKILL.md" } },
|
|
{ source: "extension", name: "memory-skills", sourceInfo: { path: "/tmp/ignore/SKILL.md" } },
|
|
null as any,
|
|
] as any);
|
|
|
|
assert.strictEqual(loaded.length, 1);
|
|
assert.strictEqual(loaded[0]?.name, "valid");
|
|
assert.strictEqual(loaded[0]?.path, "/tmp/valid/SKILL.md");
|
|
});
|
|
|
|
it("buildUnifiedSkillRows merges managed and external skills", () => {
|
|
const loaded = collectLoadedSkillsFromCommands(LOADED_SKILL_COMMANDS as any);
|
|
const rows = buildUnifiedSkillRows(SAMPLE_SKILLS, loaded);
|
|
|
|
assert.strictEqual(rows.length, 3);
|
|
const external = rows.find((row) => row.category === "E");
|
|
assert.ok(external);
|
|
assert.strictEqual(external?.mutable, false);
|
|
assert.ok(external?.displayPath.includes(".agents"));
|
|
});
|
|
|
|
it("buildUnifiedSkillRows keeps managed skills sorted by updated recency", () => {
|
|
const loaded = collectLoadedSkillsFromCommands(LOADED_SKILL_COMMANDS as any);
|
|
const rows = buildUnifiedSkillRows(SAMPLE_SKILLS, loaded);
|
|
|
|
assert.strictEqual(rows[0]?.skillId, "global:debug-typescript-errors");
|
|
assert.strictEqual(rows[1]?.skillId, "project:demo-project:deploy-checklist");
|
|
assert.strictEqual(rows[2]?.category, "E");
|
|
});
|
|
|
|
it("buildUnifiedSkillRows can sort by created date or name", () => {
|
|
const loaded = collectLoadedSkillsFromCommands(LOADED_SKILL_COMMANDS as any);
|
|
|
|
const createdRows = buildUnifiedSkillRows(SORT_MODE_SKILLS, loaded, new Set<string>(), "created");
|
|
assert.strictEqual(createdRows[0]?.skillId, "project:demo-project:alpha-checklist");
|
|
assert.strictEqual(createdRows[1]?.skillId, "global:zebra-runbook");
|
|
|
|
const nameRows = buildUnifiedSkillRows(SORT_MODE_SKILLS, loaded, new Set<string>(), "name");
|
|
assert.strictEqual(nameRows[0]?.skillId, "project:demo-project:alpha-checklist");
|
|
assert.strictEqual(nameRows[1]?.displayName, "debug-typescript-errors");
|
|
assert.strictEqual(nameRows[2]?.displayName, "langgraph-fundamentals");
|
|
assert.strictEqual(nameRows[3]?.skillId, "global:zebra-runbook");
|
|
});
|
|
|
|
it("moveSelectedSkills blocks project moves without an active project", async () => {
|
|
const store = {
|
|
getProjectName: () => null,
|
|
loadIndex: async () => SAMPLE_SKILLS,
|
|
move: async () => ({ success: true } as SkillResult),
|
|
};
|
|
|
|
const result = await moveSelectedSkills(store as any, ["global:debug-typescript-errors"], "project");
|
|
|
|
assert.strictEqual(result.summaryLines[0], "Move to project is unavailable: no active project detected.");
|
|
assert.deepStrictEqual(result.retainSelectedSkillIds, ["global:debug-typescript-errors"]);
|
|
});
|
|
|
|
it("moveSelectedSkills keeps partial successes and retains blocked selection", async () => {
|
|
const moves = new Map<string, SkillResult>([
|
|
[
|
|
"global:debug-typescript-errors",
|
|
{
|
|
success: true,
|
|
skillId: "project:demo-project:debug-typescript-errors",
|
|
scope: "project",
|
|
message: "Skill 'Debug TypeScript Errors' moved to project.",
|
|
},
|
|
],
|
|
[
|
|
"project:demo-project:deploy-checklist",
|
|
{
|
|
success: false,
|
|
error: "Destination already exists.",
|
|
conflictType: "scope-conflict",
|
|
},
|
|
],
|
|
]);
|
|
|
|
const refreshed: SkillIndex[] = [
|
|
{
|
|
...SAMPLE_SKILLS[1],
|
|
skillId: "project:demo-project:debug-typescript-errors",
|
|
name: "debug-typescript-errors",
|
|
displayName: "Debug TypeScript Errors",
|
|
path: "/tmp/project/debug-typescript-errors/SKILL.md",
|
|
description: "Trace compiler issues step by step",
|
|
},
|
|
SAMPLE_SKILLS[1],
|
|
];
|
|
|
|
const store = {
|
|
getProjectName: () => "demo-project",
|
|
loadIndex: async () => refreshed,
|
|
move: async (skillId: string) => moves.get(skillId)!,
|
|
};
|
|
|
|
const result = await moveSelectedSkills(
|
|
store as any,
|
|
["global:debug-typescript-errors", "project:demo-project:deploy-checklist"],
|
|
"project",
|
|
);
|
|
|
|
assert.ok(result.summaryLines[0].includes("Moved 1 skill"));
|
|
assert.ok(result.summaryLines.some((line) => line.includes("Blocked 1 skill")));
|
|
assert.deepStrictEqual(result.retainSelectedSkillIds, ["project:demo-project:deploy-checklist"]);
|
|
assert.strictEqual(result.focusSkillId, "project:demo-project:deploy-checklist");
|
|
assert.strictEqual(result.skills.length, 2);
|
|
});
|
|
|
|
it("deleteSelectedSkills reports blocked deletes and refreshes skills", async () => {
|
|
const store = {
|
|
loadIndex: async () => [SAMPLE_SKILLS[1]],
|
|
delete: async (skillId: string) => skillId === SAMPLE_SKILLS[0].skillId
|
|
? { success: true, skillId, scope: "global" as const }
|
|
: { success: false, error: "Skill missing." },
|
|
};
|
|
|
|
const result = await deleteSelectedSkills(
|
|
store as any,
|
|
[SAMPLE_SKILLS[0].skillId, SAMPLE_SKILLS[1].skillId],
|
|
);
|
|
|
|
assert.ok(result.summaryLines[0].includes("Deleted 1 skill"));
|
|
assert.ok(result.summaryLines.some((line) => line.includes("Blocked 1 skill")));
|
|
assert.deepStrictEqual(result.retainSelectedSkillIds, [SAMPLE_SKILLS[1].skillId]);
|
|
});
|
|
|
|
it("moveSelectedSkills treats thrown move errors as blocked items", async () => {
|
|
const store = {
|
|
getProjectName: () => "demo-project",
|
|
loadIndex: async () => SAMPLE_SKILLS,
|
|
move: async (skillId: string) => {
|
|
if (skillId === SAMPLE_SKILLS[0].skillId) {
|
|
throw new Error("permission denied");
|
|
}
|
|
return { success: true, skillId: "global:deploy-checklist", scope: "global" as const };
|
|
},
|
|
};
|
|
|
|
const result = await moveSelectedSkills(store as any, [SAMPLE_SKILLS[0].skillId, SAMPLE_SKILLS[1].skillId], "global");
|
|
|
|
assert.ok(result.summaryLines.some((line) => line.includes("Blocked 1 skill")));
|
|
assert.ok(result.summaryLines.some((line) => line.includes("permission denied")));
|
|
assert.deepStrictEqual(result.retainSelectedSkillIds, [SAMPLE_SKILLS[0].skillId]);
|
|
});
|
|
|
|
it("deleteSelectedSkills treats thrown delete errors as blocked items", async () => {
|
|
const store = {
|
|
loadIndex: async () => SAMPLE_SKILLS,
|
|
delete: async (skillId: string) => {
|
|
if (skillId === SAMPLE_SKILLS[1].skillId) {
|
|
throw new Error("unlink denied");
|
|
}
|
|
return { success: true, skillId, scope: "global" as const };
|
|
},
|
|
};
|
|
|
|
const result = await deleteSelectedSkills(store as any, [SAMPLE_SKILLS[0].skillId, SAMPLE_SKILLS[1].skillId]);
|
|
|
|
assert.ok(result.summaryLines.some((line) => line.includes("Blocked 1 skill")));
|
|
assert.ok(result.summaryLines.some((line) => line.includes("unlink denied")));
|
|
assert.deepStrictEqual(result.retainSelectedSkillIds, [SAMPLE_SKILLS[1].skillId]);
|
|
});
|
|
|
|
it("confirmDeleteSelectedSkills keeps selection when user cancels", async () => {
|
|
let prompts = 0;
|
|
const store = {
|
|
loadIndex: async () => SAMPLE_SKILLS,
|
|
delete: async () => ({ success: true }),
|
|
};
|
|
|
|
const result = await confirmDeleteSelectedSkills(
|
|
async () => {
|
|
prompts++;
|
|
return false;
|
|
},
|
|
store as any,
|
|
[SAMPLE_SKILLS[0].skillId],
|
|
);
|
|
|
|
assert.strictEqual(prompts, 1);
|
|
assert.deepStrictEqual(result.retainSelectedSkillIds, [SAMPLE_SKILLS[0].skillId]);
|
|
assert.strictEqual(result.summaryLines[0], "Delete cancelled.");
|
|
});
|
|
});
|
|
|
|
function createModalHarness() {
|
|
let renderCount = 0;
|
|
return {
|
|
tui: {
|
|
requestRender: () => {
|
|
renderCount++;
|
|
},
|
|
terminal: { rows: 42 },
|
|
},
|
|
theme: {
|
|
fg: (_color: string, text: string) => text,
|
|
bg: (_color: string, text: string) => text,
|
|
bold: (text: string) => text,
|
|
},
|
|
getRenderCount: () => renderCount,
|
|
};
|
|
}
|
|
|
|
function nextTick(): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
describe("SkillsManagerModal", () => {
|
|
it("toggles selection and sends selected ids for move action", async () => {
|
|
const harness = createModalHarness();
|
|
const captured: Array<{ scope: string; skillIds: string[] }> = [];
|
|
|
|
const modal = new SkillsManagerModal(
|
|
harness.tui as any,
|
|
harness.theme as any,
|
|
buildSkillRows(SAMPLE_SKILLS),
|
|
{
|
|
moveSelected: async (scope, skillIds) => {
|
|
captured.push({ scope, skillIds });
|
|
return { skills: SAMPLE_SKILLS, summaryLines: ["done"] };
|
|
},
|
|
deleteSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
|
close: () => undefined,
|
|
projectName: "demo-project",
|
|
},
|
|
);
|
|
|
|
modal.handleInput(" ");
|
|
modal.handleInput("g");
|
|
await nextTick();
|
|
|
|
assert.strictEqual(captured.length, 1);
|
|
assert.strictEqual(captured[0].scope, "global");
|
|
assert.deepStrictEqual(captured[0].skillIds, ["global:debug-typescript-errors"]);
|
|
});
|
|
|
|
it("supports slash search and typed filtering", () => {
|
|
const harness = createModalHarness();
|
|
const modal = new SkillsManagerModal(
|
|
harness.tui as any,
|
|
harness.theme as any,
|
|
buildSkillRows(SAMPLE_SKILLS),
|
|
{
|
|
moveSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
|
deleteSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
|
close: () => undefined,
|
|
projectName: "demo-project",
|
|
},
|
|
);
|
|
|
|
modal.handleInput("/");
|
|
modal.handleInput("z");
|
|
modal.handleInput("z");
|
|
|
|
const output = modal.render(100).join("\n");
|
|
assert.ok(output.includes("No skills match the current filters/search."));
|
|
});
|
|
|
|
it("redirects printable keys to search from list focus", () => {
|
|
const harness = createModalHarness();
|
|
const modal = new SkillsManagerModal(
|
|
harness.tui as any,
|
|
harness.theme as any,
|
|
buildSkillRows(SAMPLE_SKILLS),
|
|
{
|
|
moveSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
|
deleteSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
|
close: () => undefined,
|
|
projectName: "demo-project",
|
|
},
|
|
);
|
|
|
|
modal.handleInput("z");
|
|
const output = modal.render(100).join("\n");
|
|
assert.ok(output.includes("No skills match the current filters/search."));
|
|
});
|
|
|
|
it("uses in-modal delete confirmation and cancels with n", () => {
|
|
const harness = createModalHarness();
|
|
let deleteCalls = 0;
|
|
|
|
const modal = new SkillsManagerModal(
|
|
harness.tui as any,
|
|
harness.theme as any,
|
|
buildSkillRows(SAMPLE_SKILLS),
|
|
{
|
|
moveSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
|
deleteSelected: async () => {
|
|
deleteCalls++;
|
|
return { skills: SAMPLE_SKILLS, summaryLines: ["deleted"] };
|
|
},
|
|
close: () => undefined,
|
|
projectName: "demo-project",
|
|
},
|
|
);
|
|
|
|
modal.handleInput(" ");
|
|
modal.handleInput("d");
|
|
let output = modal.render(100).join("\n");
|
|
assert.ok(output.includes("Press y to confirm or n to cancel"));
|
|
|
|
modal.handleInput("n");
|
|
output = modal.render(100).join("\n");
|
|
assert.ok(output.includes("Delete cancelled."));
|
|
assert.strictEqual(deleteCalls, 0);
|
|
});
|
|
|
|
it("confirms delete in-modal with y", async () => {
|
|
const harness = createModalHarness();
|
|
const capturedDeletes: string[][] = [];
|
|
|
|
const modal = new SkillsManagerModal(
|
|
harness.tui as any,
|
|
harness.theme as any,
|
|
buildSkillRows(SAMPLE_SKILLS),
|
|
{
|
|
moveSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
|
deleteSelected: async (skillIds) => {
|
|
capturedDeletes.push(skillIds);
|
|
return { skills: [SAMPLE_SKILLS[1]], summaryLines: ["deleted"] };
|
|
},
|
|
close: () => undefined,
|
|
projectName: "demo-project",
|
|
},
|
|
);
|
|
|
|
modal.handleInput(" ");
|
|
modal.handleInput("d");
|
|
modal.handleInput("y");
|
|
await nextTick();
|
|
|
|
assert.strictEqual(capturedDeletes.length, 1);
|
|
assert.deepStrictEqual(capturedDeletes[0], ["global:debug-typescript-errors"]);
|
|
});
|
|
|
|
it("stops rendering updates after close during async actions", async () => {
|
|
const harness = createModalHarness();
|
|
let resolveMove: ((result: SkillBatchActionResult) => void) | null = null;
|
|
let closeCount = 0;
|
|
|
|
const modal = new SkillsManagerModal(
|
|
harness.tui as any,
|
|
harness.theme as any,
|
|
buildSkillRows(SAMPLE_SKILLS),
|
|
{
|
|
moveSelected: async () => {
|
|
return await new Promise<SkillBatchActionResult>((resolve) => {
|
|
resolveMove = resolve;
|
|
});
|
|
},
|
|
deleteSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
|
close: () => {
|
|
closeCount++;
|
|
},
|
|
projectName: "demo-project",
|
|
},
|
|
);
|
|
|
|
modal.handleInput("g");
|
|
modal.handleInput("\u001b");
|
|
assert.strictEqual(closeCount, 1);
|
|
|
|
const renderCountBeforeResolve = harness.getRenderCount();
|
|
resolveMove?.({ skills: SAMPLE_SKILLS, summaryLines: ["moved"] });
|
|
await nextTick();
|
|
|
|
assert.strictEqual(harness.getRenderCount(), renderCountBeforeResolve);
|
|
});
|
|
|
|
it("supports in-modal category filters", () => {
|
|
const harness = createModalHarness();
|
|
const loaded = collectLoadedSkillsFromCommands(LOADED_SKILL_COMMANDS as any);
|
|
const rows = buildUnifiedSkillRows(SAMPLE_SKILLS, loaded);
|
|
|
|
const modal = new SkillsManagerModal(
|
|
harness.tui as any,
|
|
harness.theme as any,
|
|
rows,
|
|
{
|
|
moveSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
|
deleteSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
|
close: () => undefined,
|
|
projectName: "demo-project",
|
|
},
|
|
{ managedSkills: SAMPLE_SKILLS, loadedSkills: loaded },
|
|
);
|
|
|
|
modal.handleInput("f");
|
|
modal.handleInput(" "); // disable global
|
|
modal.handleInput("\u001b[B");
|
|
modal.handleInput(" "); // disable project
|
|
modal.handleInput("\r"); // apply
|
|
|
|
const output = modal.render(120).join("\n");
|
|
assert.ok(output.includes("langgraph-fundamentals"));
|
|
assert.ok(!output.includes("Deploy Checklist"));
|
|
});
|
|
|
|
it("cycles sort mode with s and shows the active mode in the modal", () => {
|
|
const harness = createModalHarness();
|
|
const loaded = collectLoadedSkillsFromCommands(LOADED_SKILL_COMMANDS as any);
|
|
const rows = buildUnifiedSkillRows(SORT_MODE_SKILLS, loaded);
|
|
|
|
const modal = new SkillsManagerModal(
|
|
harness.tui as any,
|
|
harness.theme as any,
|
|
rows,
|
|
{
|
|
moveSelected: async () => ({ skills: SORT_MODE_SKILLS, summaryLines: ["done"] }),
|
|
deleteSelected: async () => ({ skills: SORT_MODE_SKILLS, summaryLines: ["done"] }),
|
|
close: () => undefined,
|
|
projectName: "demo-project",
|
|
},
|
|
{ managedSkills: SORT_MODE_SKILLS, loadedSkills: loaded },
|
|
);
|
|
|
|
let output = modal.render(120).join("\n");
|
|
assert.ok(output.includes("sort: Updated"));
|
|
assert.ok(output.includes("Zebra Runbook"));
|
|
|
|
modal.handleInput("s");
|
|
output = modal.render(120).join("\n");
|
|
assert.ok(output.includes("sort: Created"));
|
|
assert.ok(output.includes("Sort mode: Created."));
|
|
|
|
modal.handleInput("s");
|
|
output = modal.render(120).join("\n");
|
|
assert.ok(output.includes("sort: Name"));
|
|
assert.ok(output.includes("Alpha Checklist"));
|
|
});
|
|
|
|
it("blocks external skill mutations as read-only", async () => {
|
|
const harness = createModalHarness();
|
|
const loaded = collectLoadedSkillsFromCommands(LOADED_SKILL_COMMANDS as any);
|
|
const rows = buildUnifiedSkillRows(SAMPLE_SKILLS, loaded);
|
|
let moveCalls = 0;
|
|
|
|
const modal = new SkillsManagerModal(
|
|
harness.tui as any,
|
|
harness.theme as any,
|
|
rows,
|
|
{
|
|
moveSelected: async () => {
|
|
moveCalls++;
|
|
return { skills: SAMPLE_SKILLS, summaryLines: ["done"] };
|
|
},
|
|
deleteSelected: async () => ({ skills: SAMPLE_SKILLS, summaryLines: ["done"] }),
|
|
close: () => undefined,
|
|
projectName: "demo-project",
|
|
},
|
|
{ managedSkills: SAMPLE_SKILLS, loadedSkills: loaded },
|
|
);
|
|
|
|
modal.handleInput("f");
|
|
modal.handleInput(" ");
|
|
modal.handleInput("\u001b[B");
|
|
modal.handleInput(" ");
|
|
modal.handleInput("\r");
|
|
|
|
modal.handleInput(" ");
|
|
modal.handleInput("g");
|
|
await nextTick();
|
|
|
|
const output = modal.render(120).join("\n");
|
|
assert.strictEqual(moveCalls, 0);
|
|
assert.ok(output.includes("read-only"));
|
|
});
|
|
});
|
|
|
|
describe("registerSkillsCommand", () => {
|
|
it("falls back to notify output when custom UI is unavailable", async () => {
|
|
const commands: Array<{ name: string; handler: Function }> = [];
|
|
const notifications: Array<{ message: string; severity: string }> = [];
|
|
const pi = {
|
|
registerCommand: (name: string, opts: { handler: Function }) => {
|
|
commands.push({ name, handler: opts.handler });
|
|
},
|
|
};
|
|
const store = {
|
|
loadIndex: async () => SAMPLE_SKILLS,
|
|
getProjectName: () => "demo-project",
|
|
};
|
|
|
|
registerSkillsCommand(pi as any, store as any);
|
|
assert.strictEqual(commands.length, 1);
|
|
|
|
await commands[0].handler({}, {
|
|
hasUI: false,
|
|
ui: {
|
|
notify: (message: string, severity: string) => notifications.push({ message, severity }),
|
|
},
|
|
});
|
|
|
|
assert.strictEqual(notifications.length, 1);
|
|
assert.strictEqual(notifications[0].severity, "info");
|
|
assert.ok(notifications[0].message.includes("Procedural Skills"));
|
|
});
|
|
|
|
it("uses pi.getCommands runtime inventory for external skills", async () => {
|
|
const commands: Array<{ name: string; handler: Function }> = [];
|
|
const notifications: Array<{ message: string; severity: string }> = [];
|
|
const pi = {
|
|
registerCommand: (name: string, opts: { handler: Function }) => {
|
|
commands.push({ name, handler: opts.handler });
|
|
},
|
|
getCommands: () => LOADED_SKILL_COMMANDS,
|
|
};
|
|
const store = {
|
|
loadIndex: async () => SAMPLE_SKILLS,
|
|
getProjectName: () => "demo-project",
|
|
};
|
|
|
|
registerSkillsCommand(pi as any, store as any);
|
|
|
|
await commands[0].handler({}, {
|
|
hasUI: false,
|
|
ui: {
|
|
notify: (message: string, severity: string) => notifications.push({ message, severity }),
|
|
},
|
|
});
|
|
|
|
assert.strictEqual(notifications.length, 1);
|
|
assert.strictEqual(notifications[0].severity, "info");
|
|
assert.ok(notifications[0].message.includes("[E] External Skills"));
|
|
assert.ok(notifications[0].message.includes("langgraph-fundamentals"));
|
|
});
|
|
|
|
it("gracefully handles getCommands errors without custom UI", async () => {
|
|
const commands: Array<{ name: string; handler: Function }> = [];
|
|
const notifications: Array<{ message: string; severity: string }> = [];
|
|
const pi = {
|
|
registerCommand: (name: string, opts: { handler: Function }) => {
|
|
commands.push({ name, handler: opts.handler });
|
|
},
|
|
};
|
|
const store = {
|
|
loadIndex: async () => SAMPLE_SKILLS,
|
|
getProjectName: () => "demo-project",
|
|
};
|
|
|
|
registerSkillsCommand(pi as any, store as any);
|
|
assert.strictEqual(commands.length, 1);
|
|
|
|
await commands[0].handler({}, {
|
|
hasUI: false,
|
|
getCommands: () => {
|
|
throw new Error("command registry unavailable");
|
|
},
|
|
ui: {
|
|
notify: (message: string, severity: string) => notifications.push({ message, severity }),
|
|
},
|
|
});
|
|
|
|
assert.strictEqual(notifications.length, 1);
|
|
assert.strictEqual(notifications[0].severity, "info");
|
|
assert.ok(notifications[0].message.includes("Procedural Skills"));
|
|
});
|
|
|
|
it("opens a custom modal when interactive UI is available", async () => {
|
|
const commands: Array<{ name: string; handler: Function }> = [];
|
|
let customInvoked = false;
|
|
const pi = {
|
|
registerCommand: (name: string, opts: { handler: Function }) => {
|
|
commands.push({ name, handler: opts.handler });
|
|
},
|
|
};
|
|
const store = {
|
|
loadIndex: async () => SAMPLE_SKILLS,
|
|
getProjectName: () => "demo-project",
|
|
move: async () => ({ success: true } as SkillResult),
|
|
delete: async () => ({ success: true } as SkillResult),
|
|
};
|
|
|
|
registerSkillsCommand(pi as any, store as any);
|
|
|
|
await commands[0].handler({}, {
|
|
hasUI: true,
|
|
ui: {
|
|
custom: async (
|
|
factory: Function,
|
|
options: { overlay?: boolean },
|
|
) => {
|
|
customInvoked = true;
|
|
assert.strictEqual(options.overlay, true);
|
|
// factory invocation is unnecessary for this contract-level test
|
|
return undefined;
|
|
},
|
|
confirm: async () => true,
|
|
notify: () => undefined,
|
|
},
|
|
});
|
|
|
|
assert.strictEqual(customInvoked, true);
|
|
});
|
|
|
|
it("opens custom modal even when getCommands throws", async () => {
|
|
const commands: Array<{ name: string; handler: Function }> = [];
|
|
let customInvoked = false;
|
|
const pi = {
|
|
registerCommand: (name: string, opts: { handler: Function }) => {
|
|
commands.push({ name, handler: opts.handler });
|
|
},
|
|
};
|
|
const store = {
|
|
loadIndex: async () => SAMPLE_SKILLS,
|
|
getProjectName: () => "demo-project",
|
|
move: async () => ({ success: true } as SkillResult),
|
|
delete: async () => ({ success: true } as SkillResult),
|
|
};
|
|
|
|
registerSkillsCommand(pi as any, store as any);
|
|
|
|
await commands[0].handler({}, {
|
|
hasUI: true,
|
|
getCommands: () => {
|
|
throw new Error("command registry unavailable");
|
|
},
|
|
ui: {
|
|
custom: async (factory: Function, options: { overlay?: boolean }) => {
|
|
customInvoked = true;
|
|
assert.strictEqual(options.overlay, true);
|
|
return undefined;
|
|
},
|
|
confirm: async () => true,
|
|
notify: () => undefined,
|
|
},
|
|
});
|
|
|
|
assert.strictEqual(customInvoked, true);
|
|
});
|
|
|
|
it("falls back to read-only notify output when custom modal throws", async () => {
|
|
const commands: Array<{ name: string; handler: Function }> = [];
|
|
const notifications: Array<{ message: string; severity: string }> = [];
|
|
const pi = {
|
|
registerCommand: (name: string, opts: { handler: Function }) => {
|
|
commands.push({ name, handler: opts.handler });
|
|
},
|
|
};
|
|
const store = {
|
|
loadIndex: async () => SAMPLE_SKILLS,
|
|
getProjectName: () => "demo-project",
|
|
move: async () => ({ success: true } as SkillResult),
|
|
delete: async () => ({ success: true } as SkillResult),
|
|
};
|
|
|
|
registerSkillsCommand(pi as any, store as any);
|
|
|
|
await commands[0].handler({}, {
|
|
hasUI: true,
|
|
ui: {
|
|
custom: async () => {
|
|
throw new Error("UI backend unavailable");
|
|
},
|
|
confirm: async () => true,
|
|
notify: (message: string, severity: string) => notifications.push({ message, severity }),
|
|
},
|
|
});
|
|
|
|
assert.strictEqual(notifications.length, 2);
|
|
assert.strictEqual(notifications[0].severity, "warning");
|
|
assert.ok(notifications[0].message.includes("read-only list fallback"));
|
|
assert.strictEqual(notifications[1].severity, "info");
|
|
assert.ok(notifications[1].message.includes("Procedural Skills"));
|
|
});
|
|
|
|
it("falls back to read-only list when both custom modal and getCommands fail", async () => {
|
|
const commands: Array<{ name: string; handler: Function }> = [];
|
|
const notifications: Array<{ message: string; severity: string }> = [];
|
|
const pi = {
|
|
registerCommand: (name: string, opts: { handler: Function }) => {
|
|
commands.push({ name, handler: opts.handler });
|
|
},
|
|
};
|
|
const store = {
|
|
loadIndex: async () => SAMPLE_SKILLS,
|
|
getProjectName: () => "demo-project",
|
|
move: async () => ({ success: true } as SkillResult),
|
|
delete: async () => ({ success: true } as SkillResult),
|
|
};
|
|
|
|
registerSkillsCommand(pi as any, store as any);
|
|
|
|
await commands[0].handler({}, {
|
|
hasUI: true,
|
|
getCommands: () => {
|
|
throw new Error("command registry unavailable");
|
|
},
|
|
ui: {
|
|
custom: async () => {
|
|
throw new Error("UI backend unavailable");
|
|
},
|
|
confirm: async () => true,
|
|
notify: (message: string, severity: string) => notifications.push({ message, severity }),
|
|
},
|
|
});
|
|
|
|
assert.strictEqual(notifications.length, 2);
|
|
assert.strictEqual(notifications[0].severity, "warning");
|
|
assert.ok(notifications[0].message.includes("read-only list fallback"));
|
|
assert.strictEqual(notifications[1].severity, "info");
|
|
assert.ok(notifications[1].message.includes("Procedural Skills"));
|
|
});
|
|
});
|