feat(sproutclaw): modularize webui, extensions, and agent config layout
Some checks failed
CI / build-check-test (push) Has been cancelled

Restructure local extensions into per-feature directories, split WebUI
into backend modules with slash commands and systemd support, and track
prompts/skills under .pi/agent for portable Gitea deployment.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-06-10 16:57:08 +08:00
parent 11c3a3a399
commit cf5edd6394
132 changed files with 9288 additions and 1971 deletions

View File

@@ -0,0 +1,3 @@
# Skills
Documentation for this skills directory. Not a skill file.

View File

@@ -0,0 +1,6 @@
---
name: nested-skill
description: Nested skill beside a README.
---
# Nested Skill

View File

@@ -287,6 +287,8 @@ describe("InteractiveMode.showLoadedResources", () => {
) => (InteractiveMode as any).prototype.getCompactNonPackageExtensionLabel.call(fakeThis, p, index, allPaths),
getCompactExtensionLabels: (extensions: ExtensionFixture[]) =>
(InteractiveMode as any).prototype.getCompactExtensionLabels.call(fakeThis, extensions),
splitExtensionsByOrigin: (extensions: ExtensionFixture[]) =>
(InteractiveMode as any).prototype.splitExtensionsByOrigin.call(fakeThis, extensions),
formatDiagnostics: () => "diagnostics",
getBuiltInCommandConflictDiagnostics: () => [],
};
@@ -415,7 +417,7 @@ describe("InteractiveMode.showLoadedResources", () => {
});
const output = renderAll(fakeThis.chatContainer);
expect(output).toContain("[Skills]");
expect(output).toContain("[技能]");
expect(output).toContain("commit");
expect(output).not.toContain("resource-list");
});
@@ -432,7 +434,7 @@ describe("InteractiveMode.showLoadedResources", () => {
});
const output = renderAll(fakeThis.chatContainer);
expect(output).toContain("[Skills]");
expect(output).toContain("[技能]");
expect(output).toContain("resource-list");
expect(output).not.toContain("commit");
});
@@ -450,7 +452,7 @@ describe("InteractiveMode.showLoadedResources", () => {
});
const output = renderAll(fakeThis.chatContainer);
expect(output).toContain("[Skills]");
expect(output).toContain("[技能]");
expect(output).toContain("resource-list");
expect(output).not.toContain("commit");
});
@@ -466,8 +468,9 @@ describe("InteractiveMode.showLoadedResources", () => {
});
const output = renderAll(fakeThis.chatContainer);
expect(output).toContain("[Extensions]");
expect(output).toContain("answer.ts, btw.ts");
expect(output).toContain("[本地扩展]");
expect(output).toContain("answer.ts");
expect(output).toContain("btw.ts");
expect(output).not.toContain("extensions/answer.ts");
});
@@ -483,8 +486,12 @@ describe("InteractiveMode.showLoadedResources", () => {
});
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
"[Extensions]
@scope/pi-scoped, answer.ts, cli-extension.ts, HazAT/pi-interactive-subagents, HazAT/pi-interactive-subagents:subagents, local-index, pi-markdown-preview, user-index"`);
"[本地扩展] 4
answer.ts cli-extension.ts local-index user-index
[npm 扩展] 4
@scope/pi-scoped HazAT/pi-interactive-subagents HazAT/pi-interactive-subagents:subagents pi-markdown-preview"
`);
});
test("adds more parent folders until local extension labels are unique", () => {
@@ -529,8 +536,9 @@ describe("InteractiveMode.showLoadedResources", () => {
});
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
"[Extensions]
alpha/one, beta/one, gamma/one"`);
"[本地扩展] 3
alpha/one beta/one gamma/one"
`);
});
test("strips index.ts from local extension label, showing parent dir", () => {
@@ -557,8 +565,9 @@ describe("InteractiveMode.showLoadedResources", () => {
});
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
"[Extensions]
plan-mode"`);
"[本地扩展] 1
plan-mode"
`);
});
test("strips index.js from local extension label, showing parent dir", () => {
@@ -585,8 +594,9 @@ describe("InteractiveMode.showLoadedResources", () => {
});
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
"[Extensions]
plan-mode"`);
"[本地扩展] 1
plan-mode"
`);
});
test("mixed single-file and subdirectory index.ts extensions strip index.ts", () => {
@@ -622,8 +632,9 @@ describe("InteractiveMode.showLoadedResources", () => {
});
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
"[Extensions]
plan-mode, webfetch.ts"`);
"[本地扩展] 2
plan-mode webfetch.ts"
`);
});
test("multiple index.ts with unique parent dirs need no disambiguation", () => {
@@ -659,8 +670,9 @@ describe("InteractiveMode.showLoadedResources", () => {
});
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
"[Extensions]
bar, foo"`);
"[本地扩展] 2
bar foo"
`);
});
test("multiple index.ts with same parent dir name disambiguated with grandparent", () => {
@@ -696,8 +708,9 @@ describe("InteractiveMode.showLoadedResources", () => {
});
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
"[Extensions]
alpha/tools, beta/tools"`);
"[本地扩展] 2
alpha/tools beta/tools"
`);
});
test("non-index file in subdirectory stays as filename", () => {
@@ -724,8 +737,9 @@ describe("InteractiveMode.showLoadedResources", () => {
});
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
"[Extensions]
main.ts"`);
"[本地扩展] 1
main.ts"
`);
});
test("package extensions still strip index.ts correctly (regression guard)", () => {
@@ -752,8 +766,9 @@ describe("InteractiveMode.showLoadedResources", () => {
});
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
"[Extensions]
pi-markdown-preview"`);
"[npm 扩展] 1
pi-markdown-preview"
`);
});
test("captures mixed extension layouts in expanded output", () => {
const fakeThis = createShowLoadedResourcesThis({
@@ -768,21 +783,25 @@ describe("InteractiveMode.showLoadedResources", () => {
});
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toMatchInlineSnapshot(`
"[Extensions]
project
/tmp/project/.pi/extensions/answer.ts
/tmp/project/.pi/extensions/local-index
git:github.com/HazAT/pi-interactive-subagents
extensions
extensions/subagents
npm:@scope/pi-scoped
extensions
npm:pi-markdown-preview
extensions
user
/tmp/agent/extensions/user-index
path
/tmp/temp/cli-extension.ts"`);
"[本地扩展] 4
project
/tmp/project/.pi/extensions/answer.ts
/tmp/project/.pi/extensions/local-index
user
/tmp/agent/extensions/user-index
path
/tmp/temp/cli-extension.ts
[npm 扩展] 4
project
git:github.com/HazAT/pi-interactive-subagents
extensions
extensions/subagents
npm:@scope/pi-scoped
extensions
npm:pi-markdown-preview
extensions"
`);
});
test("shows context paths relative to cwd while preserving full external paths", () => {
@@ -799,8 +818,10 @@ describe("InteractiveMode.showLoadedResources", () => {
});
const output = renderAll(fakeThis.chatContainer).replace(/\\/g, "/");
expect(output).toContain("[Context]");
expect(output).toContain("~/.pi/agent/AGENTS.md, AGENTS.md");
expect(output).toContain("[系统设定]");
expect(output).toContain("~/.pi/agent/AGENTS.md");
expect(output).toContain("AGENTS.md");
expect(output).not.toContain("~/.pi/agent/AGENTS.md, AGENTS.md");
expect(output).not.toContain(`${cwd.replace(/\\/g, "/")}/AGENTS.md`);
});
@@ -819,7 +840,7 @@ describe("InteractiveMode.showLoadedResources", () => {
});
const output = renderAll(fakeThis.chatContainer).replace(/\\/g, "/");
expect(output).toContain("[Context]");
expect(output).toContain("[系统设定]");
expect(output).toContain("~/.pi/agent/AGENTS.md");
expect(output).toContain("~/Development/pi-mono/AGENTS.md");
expect(output).not.toContain("~/.pi/agent/AGENTS.md, AGENTS.md");
@@ -854,6 +875,6 @@ describe("InteractiveMode.showLoadedResources", () => {
const output = renderAll(fakeThis.chatContainer);
expect(output).toContain("[Skill conflicts]");
expect(output).not.toContain("[Skills]");
expect(output).not.toContain("[技能]");
});
});

View File

@@ -105,6 +105,17 @@ describe("skills", () => {
expect(diagnostics).toHaveLength(0);
});
it("should ignore README.md at the skills directory root", () => {
const { skills, diagnostics } = loadSkillsFromDir({
dir: join(fixturesDir, "skills-dir-readme"),
source: "test",
});
expect(skills).toHaveLength(1);
expect(skills[0].name).toBe("nested-skill");
expect(diagnostics).toHaveLength(0);
});
it("should prefer a directory's root SKILL.md over nested SKILL.md files", () => {
const { skills, diagnostics } = loadSkillsFromDir({
dir: join(fixturesDir, "root-skill-preferred"),