mirror of
https://github.com/cline/cline.git
synced 2026-09-05 14:14:01 +08:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f9b0aaf30 | |||
| 0957823420 | |||
| 3d5885c49c | |||
| afa6368d70 | |||
| 29520a3a6a | |||
| 841b95902f | |||
| 093e948c54 | |||
| d0c4e6b535 | |||
| 1e084e86a7 | |||
| 95218bb793 | |||
| 3a80348458 | |||
| 1fad8cecd2 | |||
| d4e3ca4002 | |||
| f98d9108e5 | |||
| 8e3a0ea3ba | |||
| 396551d46f | |||
| 1ee291160b | |||
| 8b7a8c42c5 | |||
| a48a82796f | |||
| 15460053c2 | |||
| 7df2554a11 | |||
| c3849efe17 | |||
| 181f131ff9 | |||
| 482efd8e4e | |||
| fd5f59b1a2 | |||
| 14e07ec0cf | |||
| 03c7a39279 | |||
| b3896df52a | |||
| af0cfd131a | |||
| d09b1dfe3b | |||
| 1876560c04 | |||
| 37587823d8 | |||
| c34bf8f6a6 | |||
| b66dfcb305 | |||
| 8c08c5577d | |||
| f4cec89759 | |||
| de9efc22dd | |||
| d107138a3c | |||
| 79d8638070 | |||
| 0c6cdb1172 | |||
| a09e6d7bb5 | |||
| 65df0d8029 | |||
| 76be73eb4b | |||
| 05d1206d87 | |||
| 12f7513be3 | |||
| 4c70c8a4fe | |||
| 2cd4eb78d1 | |||
| beee54664c | |||
| 1c308089ad | |||
| b6ed545588 | |||
| 2584f3fe2d | |||
| 0280bddd71 | |||
| 412c52c151 | |||
| af3ff08aa6 | |||
| 1a7f0c43b8 | |||
| baae18d2cf | |||
| ddd5fdd55a | |||
| 99ad4e53c5 | |||
| fdd2799169 | |||
| ef89b65d5a | |||
| d2bc5deecb | |||
| 4914f157b9 | |||
| fde3a25184 | |||
| dae27ba137 | |||
| f5fd4cb058 | |||
| 6474b3b606 | |||
| cec3f33ddc | |||
| 12f1df55cd | |||
| e4a633dd8b | |||
| bac0422173 | |||
| da85c5da90 | |||
| 10e80633d6 | |||
| cbf9bd8779 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Adds a new "Prompts Library" view
|
||||
+12
-2
@@ -137,6 +137,11 @@
|
||||
"title": "New Task",
|
||||
"icon": "$(add)"
|
||||
},
|
||||
{
|
||||
"command": "cline.promptsButtonClicked",
|
||||
"title": "Prompts Library",
|
||||
"icon": "$(book)"
|
||||
},
|
||||
{
|
||||
"command": "cline.mcpButtonClicked",
|
||||
"title": "MCP Servers",
|
||||
@@ -282,15 +287,20 @@
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.mcpButtonClicked",
|
||||
"command": "cline.promptsButtonClicked",
|
||||
"group": "navigation@2",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.historyButtonClicked",
|
||||
"command": "cline.mcpButtonClicked",
|
||||
"group": "navigation@3",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.historyButtonClicked",
|
||||
"group": "navigation@4",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.accountButtonClicked",
|
||||
"group": "navigation@5",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
|
||||
import "cline/common.proto";
|
||||
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_multiple_files = true;
|
||||
option java_package = "bot.cline.proto";
|
||||
|
||||
// Enum for prompt types
|
||||
enum PromptType {
|
||||
PROMPT_TYPE_UNSPECIFIED = 0;
|
||||
PROMPT_TYPE_RULE = 1;
|
||||
PROMPT_TYPE_WORKFLOW = 2;
|
||||
PROMPT_TYPE_HOOK = 3;
|
||||
PROMPT_TYPE_SKILL = 4;
|
||||
}
|
||||
|
||||
// Message for a single prompt item
|
||||
message PromptItem {
|
||||
string prompt_id = 1;
|
||||
string github_url = 2;
|
||||
string name = 3;
|
||||
string author = 4;
|
||||
string description = 5;
|
||||
string category = 6;
|
||||
repeated string tags = 7;
|
||||
PromptType type = 8;
|
||||
string content = 9;
|
||||
string version = 10;
|
||||
repeated string globs = 11;
|
||||
string created_at = 12;
|
||||
string updated_at = 13;
|
||||
}
|
||||
|
||||
// Message for prompts catalog
|
||||
message PromptsCatalog {
|
||||
repeated PromptItem items = 1;
|
||||
string last_updated = 2;
|
||||
}
|
||||
|
||||
// Request to apply a prompt
|
||||
message ApplyPromptRequest {
|
||||
string prompt_id = 1;
|
||||
PromptType type = 2;
|
||||
string content = 3;
|
||||
string name = 4;
|
||||
}
|
||||
|
||||
// Request to remove a prompt
|
||||
message RemovePromptRequest {
|
||||
string prompt_id = 1;
|
||||
PromptType type = 2;
|
||||
string name = 3;
|
||||
}
|
||||
|
||||
// PromptsService provides methods for managing prompts library
|
||||
service PromptsService {
|
||||
// Fetches the catalog of community prompts from GitHub
|
||||
rpc fetchPromptsCatalog(EmptyRequest) returns (PromptsCatalog);
|
||||
|
||||
// Applies a prompt to the workspace
|
||||
rpc applyPrompt(ApplyPromptRequest) returns (Boolean);
|
||||
|
||||
// Removes a prompt from the workspace
|
||||
rpc removePrompt(RemovePromptRequest) returns (Boolean);
|
||||
|
||||
// Subscribe to prompts catalog updates
|
||||
rpc subscribeToPromptsCatalog(EmptyRequest) returns (stream PromptsCatalog);
|
||||
|
||||
// Gets the list of currently applied prompt IDs
|
||||
rpc getAppliedPrompts(EmptyRequest) returns (StringArray);
|
||||
}
|
||||
@@ -264,6 +264,9 @@ service UiService {
|
||||
// Subscribe to worktrees button clicked events
|
||||
rpc subscribeToWorktreesButtonClicked(EmptyRequest) returns (stream Empty);
|
||||
|
||||
// Subscribe to prompts button clicked events
|
||||
rpc subscribeToPromptsButtonClicked(EmptyRequest) returns (stream Empty);
|
||||
|
||||
// Subscribe to partial message updates (streaming Cline messages as they're built)
|
||||
rpc subscribeToPartialMessage(EmptyRequest) returns (stream ClineMessage);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { WorkspaceRootManager } from "@core/workspace/WorkspaceRootManager"
|
||||
import { cleanupLegacyCheckpoints } from "@integrations/checkpoints/CheckpointMigration"
|
||||
import { ClineAccountService } from "@services/account/ClineAccountService"
|
||||
import { McpHub } from "@services/mcp/McpHub"
|
||||
import { PromptsService } from "@services/prompts/PromptsService"
|
||||
import type { ApiProvider, ModelInfo } from "@shared/api"
|
||||
import type { ChatContent } from "@shared/ChatContent"
|
||||
import type { ExtensionState, Platform } from "@shared/ExtensionMessage"
|
||||
@@ -70,6 +71,7 @@ export class Controller {
|
||||
task?: Task
|
||||
|
||||
mcpHub: McpHub
|
||||
promptsService: PromptsService
|
||||
accountService: ClineAccountService
|
||||
authService: AuthService
|
||||
ocaAuthService: OcaAuthService
|
||||
@@ -148,6 +150,8 @@ export class Controller {
|
||||
telemetryService,
|
||||
)
|
||||
|
||||
this.promptsService = new PromptsService()
|
||||
|
||||
// Clean up legacy checkpoints
|
||||
cleanupLegacyCheckpoints().catch((error) => {
|
||||
Logger.error("Failed to cleanup legacy checkpoints:", error)
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import { ApplyPromptRequest } from "@shared/proto/cline/prompts"
|
||||
import * as assert from "assert"
|
||||
import * as sinon from "sinon"
|
||||
|
||||
// Use require for proxyquire to work in this test environment
|
||||
const proxyquire = require("proxyquire")
|
||||
|
||||
// Create stubs at module scope
|
||||
const getWorkspacePathStub = sinon.stub()
|
||||
const fsMkdirStub = sinon.stub()
|
||||
const fsWriteFileStub = sinon.stub()
|
||||
const axiosGetStub = sinon.stub()
|
||||
|
||||
// Load module with proxyquire at module scope
|
||||
const { applyPrompt } = proxyquire("../applyPrompt", {
|
||||
"@/utils/path": {
|
||||
getWorkspacePath: getWorkspacePathStub,
|
||||
},
|
||||
"node:fs/promises": {
|
||||
mkdir: fsMkdirStub,
|
||||
writeFile: fsWriteFileStub,
|
||||
"@noCallThru": true,
|
||||
},
|
||||
axios: {
|
||||
get: axiosGetStub,
|
||||
default: { get: axiosGetStub },
|
||||
"@noCallThru": true,
|
||||
},
|
||||
"@/shared/net": {
|
||||
getAxiosSettings: () => ({}),
|
||||
"@noCallThru": true,
|
||||
},
|
||||
})
|
||||
|
||||
describe("applyPrompt", () => {
|
||||
let mockController: any
|
||||
|
||||
beforeEach(() => {
|
||||
mockController = {}
|
||||
// Reset stubs before each test
|
||||
getWorkspacePathStub.reset()
|
||||
fsMkdirStub.reset()
|
||||
fsWriteFileStub.reset()
|
||||
axiosGetStub.reset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe("File Operations", () => {
|
||||
it("should create .clinerules/ directory for RULE type", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsMkdirStub.resolves()
|
||||
fsWriteFileStub.resolves()
|
||||
|
||||
const request = ApplyPromptRequest.create({
|
||||
promptId: "test-prompt",
|
||||
type: 1, // RULE
|
||||
content: "# Test content",
|
||||
name: "Test Prompt",
|
||||
})
|
||||
|
||||
const result = await applyPrompt(mockController, request)
|
||||
|
||||
assert.strictEqual(result.value, true)
|
||||
assert.ok(fsMkdirStub.calledWith(sinon.match(/[/\\]workspace[/\\]\.clinerules$/)))
|
||||
})
|
||||
|
||||
it("should create .clinerules/workflows/ directory for WORKFLOW type", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsMkdirStub.resolves()
|
||||
fsWriteFileStub.resolves()
|
||||
|
||||
const request = ApplyPromptRequest.create({
|
||||
promptId: "test-workflow",
|
||||
type: 2, // WORKFLOW
|
||||
content: "# Workflow content",
|
||||
name: "Test Workflow",
|
||||
})
|
||||
|
||||
const result = await applyPrompt(mockController, request)
|
||||
|
||||
assert.strictEqual(result.value, true)
|
||||
assert.ok(fsMkdirStub.calledWith(sinon.match(/[/\\]\.clinerules[/\\]workflows$/)))
|
||||
})
|
||||
|
||||
it("should create .clinerules/hooks/ directory for HOOK type", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsMkdirStub.resolves()
|
||||
fsWriteFileStub.resolves()
|
||||
|
||||
const request = ApplyPromptRequest.create({
|
||||
promptId: "test-hook",
|
||||
type: 3, // HOOK
|
||||
content: "# Hook content",
|
||||
name: "Test Hook",
|
||||
})
|
||||
|
||||
const result = await applyPrompt(mockController, request)
|
||||
|
||||
assert.strictEqual(result.value, true)
|
||||
assert.ok(fsMkdirStub.calledWith(sinon.match(/[/\\]\.clinerules[/\\]hooks$/)))
|
||||
})
|
||||
|
||||
it("should create .clinerules/skills/{name}/ directory for SKILL type", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsMkdirStub.resolves()
|
||||
fsWriteFileStub.resolves()
|
||||
|
||||
const request = ApplyPromptRequest.create({
|
||||
promptId: "test-skill",
|
||||
type: 4, // SKILL
|
||||
content: "# Skill content",
|
||||
name: "Test Skill",
|
||||
})
|
||||
|
||||
const result = await applyPrompt(mockController, request)
|
||||
|
||||
assert.strictEqual(result.value, true)
|
||||
assert.ok(fsMkdirStub.calledWith(sinon.match(/[/\\]\.clinerules[/\\]skills[/\\]test-skill$/)))
|
||||
})
|
||||
|
||||
it("should write SKILL.md for SKILL type", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsMkdirStub.resolves()
|
||||
fsWriteFileStub.resolves()
|
||||
|
||||
const content = "# Skill content"
|
||||
const request = ApplyPromptRequest.create({
|
||||
promptId: "test-skill",
|
||||
type: 4, // SKILL
|
||||
content,
|
||||
name: "Test Skill",
|
||||
})
|
||||
|
||||
await applyPrompt(mockController, request)
|
||||
|
||||
assert.ok(fsWriteFileStub.calledWith(sinon.match(/[/\\]SKILL\.md$/), content, "utf-8"))
|
||||
})
|
||||
|
||||
it("should write file with correct content", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsMkdirStub.resolves()
|
||||
fsWriteFileStub.resolves()
|
||||
|
||||
const content = "# Test Content\n\nThis is the prompt content"
|
||||
const request = ApplyPromptRequest.create({
|
||||
promptId: "test-prompt",
|
||||
type: 1,
|
||||
content,
|
||||
name: "Test Prompt",
|
||||
})
|
||||
|
||||
await applyPrompt(mockController, request)
|
||||
|
||||
assert.ok(fsWriteFileStub.calledWith(sinon.match.string, content, "utf-8"))
|
||||
})
|
||||
|
||||
it("should create kebab-case filename from prompt name", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsMkdirStub.resolves()
|
||||
fsWriteFileStub.resolves()
|
||||
|
||||
const request = ApplyPromptRequest.create({
|
||||
promptId: "test-prompt",
|
||||
type: 1,
|
||||
content: "content",
|
||||
name: "Test Prompt With Spaces",
|
||||
})
|
||||
|
||||
await applyPrompt(mockController, request)
|
||||
|
||||
assert.ok(fsWriteFileStub.calledWith(sinon.match(/test-prompt-with-spaces\.md$/), sinon.match.any, sinon.match.any))
|
||||
})
|
||||
|
||||
it("should handle special characters in prompt name", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsMkdirStub.resolves()
|
||||
fsWriteFileStub.resolves()
|
||||
|
||||
const request = ApplyPromptRequest.create({
|
||||
promptId: "test-prompt",
|
||||
type: 1,
|
||||
content: "content",
|
||||
name: "Test_Prompt@#$%Special!Chars",
|
||||
})
|
||||
|
||||
await applyPrompt(mockController, request)
|
||||
|
||||
// Should convert to kebab-case and remove special chars
|
||||
assert.ok(fsWriteFileStub.calledWith(sinon.match(/test-prompt-special-chars\.md$/), sinon.match.any, sinon.match.any))
|
||||
})
|
||||
|
||||
it("should handle leading/trailing dashes in generated filename", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsMkdirStub.resolves()
|
||||
fsWriteFileStub.resolves()
|
||||
|
||||
const request = ApplyPromptRequest.create({
|
||||
promptId: "test-prompt",
|
||||
type: 1,
|
||||
content: "content",
|
||||
name: "---Test---",
|
||||
})
|
||||
|
||||
await applyPrompt(mockController, request)
|
||||
|
||||
// Should remove leading/trailing dashes (use [/\\] for cross-platform)
|
||||
assert.ok(fsWriteFileStub.calledWith(sinon.match(/[/\\]test\.md$/), sinon.match.any, sinon.match.any))
|
||||
})
|
||||
|
||||
it("should return success when file is written", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsMkdirStub.resolves()
|
||||
fsWriteFileStub.resolves()
|
||||
|
||||
const request = ApplyPromptRequest.create({
|
||||
promptId: "test",
|
||||
type: 1,
|
||||
content: "content",
|
||||
name: "Test",
|
||||
})
|
||||
|
||||
const result = await applyPrompt(mockController, request)
|
||||
|
||||
assert.strictEqual(result.value, true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should return false when workspace path is unavailable", async () => {
|
||||
getWorkspacePathStub.resolves(null)
|
||||
|
||||
const request = ApplyPromptRequest.create({
|
||||
promptId: "test",
|
||||
type: 1,
|
||||
content: "content",
|
||||
name: "Test",
|
||||
})
|
||||
|
||||
const result = await applyPrompt(mockController, request)
|
||||
|
||||
assert.strictEqual(result.value, false)
|
||||
})
|
||||
|
||||
it("should return false when directory creation fails", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsMkdirStub.rejects(new Error("Permission denied"))
|
||||
|
||||
const request = ApplyPromptRequest.create({
|
||||
promptId: "test",
|
||||
type: 1,
|
||||
content: "content",
|
||||
name: "Test",
|
||||
})
|
||||
|
||||
const result = await applyPrompt(mockController, request)
|
||||
|
||||
assert.strictEqual(result.value, false)
|
||||
})
|
||||
|
||||
it("should return false when file write fails", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsMkdirStub.resolves()
|
||||
fsWriteFileStub.rejects(new Error("Write failed"))
|
||||
|
||||
const request = ApplyPromptRequest.create({
|
||||
promptId: "test",
|
||||
type: 1,
|
||||
content: "content",
|
||||
name: "Test",
|
||||
})
|
||||
|
||||
const result = await applyPrompt(mockController, request)
|
||||
|
||||
assert.strictEqual(result.value, false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should overwrite existing file with same name", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsMkdirStub.resolves()
|
||||
fsWriteFileStub.resolves() // writeFile with 'utf-8' will overwrite
|
||||
|
||||
const request = ApplyPromptRequest.create({
|
||||
promptId: "existing",
|
||||
type: 1,
|
||||
content: "new content",
|
||||
name: "Existing",
|
||||
})
|
||||
|
||||
const result = await applyPrompt(mockController, request)
|
||||
|
||||
assert.strictEqual(result.value, true)
|
||||
assert.ok(fsWriteFileStub.calledOnce)
|
||||
})
|
||||
|
||||
it("should fetch content from GitHub when content is empty", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsMkdirStub.resolves()
|
||||
fsWriteFileStub.resolves()
|
||||
axiosGetStub.resolves({ data: "# Fetched content from GitHub" })
|
||||
|
||||
const request = ApplyPromptRequest.create({
|
||||
promptId: "empty",
|
||||
type: 1,
|
||||
content: "",
|
||||
name: "Empty",
|
||||
})
|
||||
|
||||
const result = await applyPrompt(mockController, request)
|
||||
|
||||
assert.strictEqual(result.value, true)
|
||||
// Should have fetched content from raw.githubusercontent.com
|
||||
assert.ok(axiosGetStub.calledOnce)
|
||||
const requestedUrl = axiosGetStub.firstCall.args[0]
|
||||
const parsedUrl = new URL(requestedUrl)
|
||||
assert.strictEqual(parsedUrl.hostname, "raw.githubusercontent.com")
|
||||
// Should write the fetched content
|
||||
assert.ok(fsWriteFileStub.calledWith(sinon.match.string, "# Fetched content from GitHub", "utf-8"))
|
||||
})
|
||||
|
||||
it("should return false when content is empty and fetch fails", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsMkdirStub.resolves()
|
||||
fsWriteFileStub.resolves()
|
||||
axiosGetStub.rejects(new Error("Network error"))
|
||||
|
||||
const request = ApplyPromptRequest.create({
|
||||
promptId: "empty",
|
||||
type: 1,
|
||||
content: "",
|
||||
name: "Empty",
|
||||
})
|
||||
|
||||
const result = await applyPrompt(mockController, request)
|
||||
|
||||
assert.strictEqual(result.value, false)
|
||||
})
|
||||
|
||||
it("should handle very long filenames", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsMkdirStub.resolves()
|
||||
fsWriteFileStub.resolves()
|
||||
|
||||
const longName = "A".repeat(300) // Very long name
|
||||
const request = ApplyPromptRequest.create({
|
||||
promptId: "long",
|
||||
type: 1,
|
||||
content: "content",
|
||||
name: longName,
|
||||
})
|
||||
|
||||
const result = await applyPrompt(mockController, request)
|
||||
|
||||
// Should still succeed (filesystem may truncate or fail, but function handles it)
|
||||
assert.ok(result.value === true || result.value === false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,322 @@
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import * as assert from "assert"
|
||||
import * as sinon from "sinon"
|
||||
|
||||
// Use require for proxyquire to work in this test environment
|
||||
const proxyquire = require("proxyquire")
|
||||
|
||||
// Create stubs at module scope
|
||||
const getWorkspacePathStub = sinon.stub()
|
||||
const fsReaddirStub = sinon.stub()
|
||||
const fsStatStub = sinon.stub()
|
||||
|
||||
// Load module with proxyquire at module scope
|
||||
const { getAppliedPrompts } = proxyquire("../getAppliedPrompts", {
|
||||
"@/utils/path": {
|
||||
getWorkspacePath: getWorkspacePathStub,
|
||||
},
|
||||
"node:fs/promises": {
|
||||
readdir: fsReaddirStub,
|
||||
stat: fsStatStub,
|
||||
"@noCallThru": true,
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Helper: stubs readdir for the four standard directories.
|
||||
* Pass arrays of filenames for each; defaults to empty.
|
||||
*/
|
||||
function stubDirectories(opts: { rules?: string[]; workflows?: string[]; hooks?: string[]; skills?: string[] }) {
|
||||
const enoent: any = new Error("ENOENT")
|
||||
enoent.code = "ENOENT"
|
||||
|
||||
// .clinerules/ (top-level rules)
|
||||
if (opts.rules) {
|
||||
fsReaddirStub.withArgs(sinon.match(/\.clinerules$/)).resolves(opts.rules as any)
|
||||
} else {
|
||||
fsReaddirStub.withArgs(sinon.match(/\.clinerules$/)).rejects(enoent)
|
||||
}
|
||||
|
||||
// .clinerules/workflows/
|
||||
if (opts.workflows) {
|
||||
fsReaddirStub.withArgs(sinon.match(/workflows$/)).resolves(opts.workflows as any)
|
||||
} else {
|
||||
fsReaddirStub.withArgs(sinon.match(/workflows$/)).rejects(enoent)
|
||||
}
|
||||
|
||||
// .clinerules/hooks/
|
||||
if (opts.hooks) {
|
||||
fsReaddirStub.withArgs(sinon.match(/hooks$/)).resolves(opts.hooks as any)
|
||||
} else {
|
||||
fsReaddirStub.withArgs(sinon.match(/hooks$/)).rejects(enoent)
|
||||
}
|
||||
|
||||
// .clinerules/skills/
|
||||
if (opts.skills) {
|
||||
fsReaddirStub.withArgs(sinon.match(/skills$/)).resolves(opts.skills as any)
|
||||
} else {
|
||||
fsReaddirStub.withArgs(sinon.match(/skills$/)).rejects(enoent)
|
||||
}
|
||||
}
|
||||
|
||||
describe("getAppliedPrompts", () => {
|
||||
let mockController: any
|
||||
|
||||
beforeEach(() => {
|
||||
mockController = {}
|
||||
// Reset stubs before each test
|
||||
getWorkspacePathStub.reset()
|
||||
fsReaddirStub.reset()
|
||||
fsStatStub.reset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe("Directory Scanning", () => {
|
||||
it("should return empty array when no workspace", async () => {
|
||||
getWorkspacePathStub.resolves(null)
|
||||
|
||||
const result = await getAppliedPrompts(mockController, EmptyRequest.create({}))
|
||||
|
||||
assert.deepStrictEqual(result.values, [])
|
||||
})
|
||||
|
||||
it("should scan .clinerules/ directory correctly", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
stubDirectories({
|
||||
rules: ["prompt1.md", "prompt2.md"],
|
||||
workflows: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
})
|
||||
|
||||
const result = await getAppliedPrompts(mockController, EmptyRequest.create({}))
|
||||
|
||||
assert.deepStrictEqual(result.values, ["rule:prompt1", "rule:prompt2"])
|
||||
})
|
||||
|
||||
it("should scan .clinerules/workflows/ directory correctly", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
stubDirectories({
|
||||
rules: [],
|
||||
workflows: ["workflow1.md", "workflow2.md"],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
})
|
||||
|
||||
const result = await getAppliedPrompts(mockController, EmptyRequest.create({}))
|
||||
|
||||
assert.deepStrictEqual(result.values, ["workflow:workflow1", "workflow:workflow2"])
|
||||
})
|
||||
|
||||
it("should scan .clinerules/hooks/ directory correctly", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
stubDirectories({
|
||||
rules: [],
|
||||
workflows: [],
|
||||
hooks: ["hook1.md", "hook2.md"],
|
||||
skills: [],
|
||||
})
|
||||
|
||||
const result = await getAppliedPrompts(mockController, EmptyRequest.create({}))
|
||||
|
||||
assert.deepStrictEqual(result.values, ["hook:hook1", "hook:hook2"])
|
||||
})
|
||||
|
||||
it("should scan .clinerules/skills/ directory correctly", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
stubDirectories({
|
||||
rules: [],
|
||||
workflows: [],
|
||||
hooks: [],
|
||||
skills: ["my-skill"],
|
||||
})
|
||||
// stat for SKILL.md inside the skill directory
|
||||
fsStatStub.withArgs(sinon.match(/my-skill[/\\]SKILL\.md$/)).resolves({ isFile: () => true })
|
||||
|
||||
const result = await getAppliedPrompts(mockController, EmptyRequest.create({}))
|
||||
|
||||
assert.deepStrictEqual(result.values, ["skill:my-skill"])
|
||||
})
|
||||
|
||||
it("should skip skill directories without SKILL.md", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
stubDirectories({
|
||||
rules: [],
|
||||
workflows: [],
|
||||
hooks: [],
|
||||
skills: ["valid-skill", "invalid-skill"],
|
||||
})
|
||||
fsStatStub.withArgs(sinon.match(/valid-skill[/\\]SKILL\.md$/)).resolves({ isFile: () => true })
|
||||
fsStatStub.withArgs(sinon.match(/invalid-skill[/\\]SKILL\.md$/)).rejects(new Error("ENOENT"))
|
||||
|
||||
const result = await getAppliedPrompts(mockController, EmptyRequest.create({}))
|
||||
|
||||
assert.deepStrictEqual(result.values, ["skill:valid-skill"])
|
||||
})
|
||||
|
||||
it("should extract prompt IDs from .md filenames", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
stubDirectories({
|
||||
rules: ["test-prompt.md", "another-prompt.md"],
|
||||
workflows: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
})
|
||||
|
||||
const result = await getAppliedPrompts(mockController, EmptyRequest.create({}))
|
||||
|
||||
assert.deepStrictEqual(result.values, ["rule:test-prompt", "rule:another-prompt"])
|
||||
})
|
||||
|
||||
it("should ignore non-.md files", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
stubDirectories({
|
||||
rules: ["prompt.md", "readme.txt", ".DS_Store", "config.json"],
|
||||
workflows: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
})
|
||||
|
||||
const result = await getAppliedPrompts(mockController, EmptyRequest.create({}))
|
||||
|
||||
assert.deepStrictEqual(result.values, ["rule:prompt"])
|
||||
})
|
||||
|
||||
it("should return combined list from all directories with type prefixes", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
stubDirectories({
|
||||
rules: ["rule1.md", "rule2.md"],
|
||||
workflows: ["workflow1.md"],
|
||||
hooks: ["hook1.md"],
|
||||
skills: ["skill1"],
|
||||
})
|
||||
fsStatStub.withArgs(sinon.match(/skill1[/\\]SKILL\.md$/)).resolves({ isFile: () => true })
|
||||
|
||||
const result = await getAppliedPrompts(mockController, EmptyRequest.create({}))
|
||||
|
||||
assert.deepStrictEqual(result.values, [
|
||||
"rule:rule1",
|
||||
"rule:rule2",
|
||||
"workflow:workflow1",
|
||||
"hook:hook1",
|
||||
"skill:skill1",
|
||||
])
|
||||
})
|
||||
|
||||
it("should not collide when rule and workflow share the same name", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
stubDirectories({
|
||||
rules: ["test-prompt.md"],
|
||||
workflows: ["test-prompt.md"],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
})
|
||||
|
||||
const result = await getAppliedPrompts(mockController, EmptyRequest.create({}))
|
||||
|
||||
assert.deepStrictEqual(result.values, ["rule:test-prompt", "workflow:test-prompt"])
|
||||
// Both should be present and distinct
|
||||
assert.strictEqual(result.values.length, 2)
|
||||
})
|
||||
|
||||
it("should handle empty directories", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
stubDirectories({
|
||||
rules: [],
|
||||
workflows: [],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
})
|
||||
|
||||
const result = await getAppliedPrompts(mockController, EmptyRequest.create({}))
|
||||
|
||||
assert.deepStrictEqual(result.values, [])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should gracefully handle missing .clinerules/ directory", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
stubDirectories({
|
||||
workflows: ["workflow.md"],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
})
|
||||
|
||||
const result = await getAppliedPrompts(mockController, EmptyRequest.create({}))
|
||||
|
||||
assert.deepStrictEqual(result.values, ["workflow:workflow"])
|
||||
})
|
||||
|
||||
it("should gracefully handle missing workflows/ directory", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
stubDirectories({
|
||||
rules: ["rule.md"],
|
||||
hooks: [],
|
||||
skills: [],
|
||||
})
|
||||
|
||||
const result = await getAppliedPrompts(mockController, EmptyRequest.create({}))
|
||||
|
||||
assert.deepStrictEqual(result.values, ["rule:rule"])
|
||||
})
|
||||
|
||||
it("should gracefully handle missing hooks/ directory", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
stubDirectories({
|
||||
rules: ["rule.md"],
|
||||
workflows: [],
|
||||
skills: [],
|
||||
})
|
||||
|
||||
const result = await getAppliedPrompts(mockController, EmptyRequest.create({}))
|
||||
|
||||
assert.deepStrictEqual(result.values, ["rule:rule"])
|
||||
})
|
||||
|
||||
it("should gracefully handle missing skills/ directory", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
stubDirectories({
|
||||
rules: ["rule.md"],
|
||||
workflows: [],
|
||||
hooks: [],
|
||||
})
|
||||
|
||||
const result = await getAppliedPrompts(mockController, EmptyRequest.create({}))
|
||||
|
||||
assert.deepStrictEqual(result.values, ["rule:rule"])
|
||||
})
|
||||
|
||||
it("should handle permission denied errors", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
const error: any = new Error("EACCES: permission denied")
|
||||
error.code = "EACCES"
|
||||
fsReaddirStub.rejects(error)
|
||||
|
||||
const result = await getAppliedPrompts(mockController, EmptyRequest.create({}))
|
||||
|
||||
// Should return empty array on permission errors
|
||||
assert.deepStrictEqual(result.values, [])
|
||||
})
|
||||
|
||||
it("should return empty array on unexpected errors", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsReaddirStub.rejects(new Error("Unexpected error"))
|
||||
|
||||
const result = await getAppliedPrompts(mockController, EmptyRequest.create({}))
|
||||
|
||||
assert.deepStrictEqual(result.values, [])
|
||||
})
|
||||
|
||||
it("should handle error in getWorkspacePath", async () => {
|
||||
getWorkspacePathStub.rejects(new Error("Workspace error"))
|
||||
|
||||
const result = await getAppliedPrompts(mockController, EmptyRequest.create({}))
|
||||
|
||||
assert.deepStrictEqual(result.values, [])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
import { RemovePromptRequest } from "@shared/proto/cline/prompts"
|
||||
import * as assert from "assert"
|
||||
import * as sinon from "sinon"
|
||||
|
||||
// Use require for proxyquire to work in this test environment
|
||||
const proxyquire = require("proxyquire")
|
||||
|
||||
// Create stubs at module scope
|
||||
const getWorkspacePathStub = sinon.stub()
|
||||
const fsUnlinkStub = sinon.stub()
|
||||
const fsRmdirStub = sinon.stub()
|
||||
|
||||
// Load module with proxyquire at module scope
|
||||
const { removePrompt } = proxyquire("../removePrompt", {
|
||||
"@/utils/path": {
|
||||
getWorkspacePath: getWorkspacePathStub,
|
||||
},
|
||||
"node:fs/promises": {
|
||||
unlink: fsUnlinkStub,
|
||||
rmdir: fsRmdirStub,
|
||||
"@noCallThru": true,
|
||||
},
|
||||
})
|
||||
|
||||
describe("removePrompt", () => {
|
||||
let mockController: any
|
||||
|
||||
beforeEach(() => {
|
||||
mockController = {}
|
||||
// Reset stubs before each test
|
||||
getWorkspacePathStub.reset()
|
||||
fsUnlinkStub.reset()
|
||||
fsRmdirStub.reset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe("File Operations", () => {
|
||||
it("should remove file from .clinerules/ for RULE type", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsUnlinkStub.resolves()
|
||||
|
||||
const request = RemovePromptRequest.create({
|
||||
promptId: "test-prompt",
|
||||
type: 1, // RULE
|
||||
name: "Test Prompt",
|
||||
})
|
||||
|
||||
const result = await removePrompt(mockController, request)
|
||||
|
||||
assert.strictEqual(result.value, true)
|
||||
assert.ok(fsUnlinkStub.calledWith(sinon.match(/\.clinerules.*test-prompt\.md$/)))
|
||||
})
|
||||
|
||||
it("should remove file from .clinerules/workflows/ for WORKFLOW type", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsUnlinkStub.resolves()
|
||||
|
||||
const request = RemovePromptRequest.create({
|
||||
promptId: "test-workflow",
|
||||
type: 2, // WORKFLOW
|
||||
name: "Test Workflow",
|
||||
})
|
||||
|
||||
const result = await removePrompt(mockController, request)
|
||||
|
||||
assert.strictEqual(result.value, true)
|
||||
assert.ok(fsUnlinkStub.calledWith(sinon.match(/\.clinerules[/\\]workflows.*test-workflow\.md$/)))
|
||||
})
|
||||
|
||||
it("should remove file from .clinerules/hooks/ for HOOK type", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsUnlinkStub.resolves()
|
||||
|
||||
const request = RemovePromptRequest.create({
|
||||
promptId: "test-hook",
|
||||
type: 3, // HOOK
|
||||
name: "Test Hook",
|
||||
})
|
||||
|
||||
const result = await removePrompt(mockController, request)
|
||||
|
||||
assert.strictEqual(result.value, true)
|
||||
assert.ok(fsUnlinkStub.calledWith(sinon.match(/\.clinerules[/\\]hooks.*test-hook\.md$/)))
|
||||
})
|
||||
|
||||
it("should remove SKILL.md from .clinerules/skills/{name}/ for SKILL type", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsUnlinkStub.resolves()
|
||||
fsRmdirStub.resolves()
|
||||
|
||||
const request = RemovePromptRequest.create({
|
||||
promptId: "test-skill",
|
||||
type: 4, // SKILL
|
||||
name: "Test Skill",
|
||||
})
|
||||
|
||||
const result = await removePrompt(mockController, request)
|
||||
|
||||
assert.strictEqual(result.value, true)
|
||||
assert.ok(fsUnlinkStub.calledWith(sinon.match(/\.clinerules[/\\]skills[/\\]test-skill[/\\]SKILL\.md$/)))
|
||||
})
|
||||
|
||||
it("should try to clean up empty skill directory after removing SKILL.md", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsUnlinkStub.resolves()
|
||||
fsRmdirStub.resolves()
|
||||
|
||||
const request = RemovePromptRequest.create({
|
||||
promptId: "test-skill",
|
||||
type: 4, // SKILL
|
||||
name: "Test Skill",
|
||||
})
|
||||
|
||||
await removePrompt(mockController, request)
|
||||
|
||||
assert.ok(fsRmdirStub.calledWith(sinon.match(/\.clinerules[/\\]skills[/\\]test-skill$/)))
|
||||
})
|
||||
|
||||
it("should succeed even if skill directory cleanup fails (non-empty)", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsUnlinkStub.resolves()
|
||||
fsRmdirStub.rejects(new Error("ENOTEMPTY"))
|
||||
|
||||
const request = RemovePromptRequest.create({
|
||||
promptId: "test-skill",
|
||||
type: 4, // SKILL
|
||||
name: "Test Skill",
|
||||
})
|
||||
|
||||
const result = await removePrompt(mockController, request)
|
||||
|
||||
assert.strictEqual(result.value, true)
|
||||
})
|
||||
|
||||
it("should return success when file is deleted", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsUnlinkStub.resolves()
|
||||
|
||||
const request = RemovePromptRequest.create({
|
||||
promptId: "test",
|
||||
type: 1,
|
||||
name: "Test",
|
||||
})
|
||||
|
||||
const result = await removePrompt(mockController, request)
|
||||
|
||||
assert.strictEqual(result.value, true)
|
||||
})
|
||||
|
||||
it("should generate correct kebab-case filename", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsUnlinkStub.resolves()
|
||||
|
||||
const request = RemovePromptRequest.create({
|
||||
promptId: "test-prompt",
|
||||
type: 1,
|
||||
name: "Test Prompt With Spaces",
|
||||
})
|
||||
|
||||
await removePrompt(mockController, request)
|
||||
|
||||
assert.ok(fsUnlinkStub.calledWith(sinon.match(/test-prompt-with-spaces\.md$/)))
|
||||
})
|
||||
})
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should return false when workspace path is unavailable", async () => {
|
||||
getWorkspacePathStub.resolves(null)
|
||||
|
||||
const request = RemovePromptRequest.create({
|
||||
promptId: "test",
|
||||
type: 1,
|
||||
name: "Test",
|
||||
})
|
||||
|
||||
const result = await removePrompt(mockController, request)
|
||||
|
||||
assert.strictEqual(result.value, false)
|
||||
})
|
||||
|
||||
it("should return false when file doesn't exist (graceful failure)", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
const error: any = new Error("ENOENT: no such file or directory")
|
||||
error.code = "ENOENT"
|
||||
fsUnlinkStub.rejects(error)
|
||||
|
||||
const request = RemovePromptRequest.create({
|
||||
promptId: "nonexistent",
|
||||
type: 1,
|
||||
name: "Nonexistent",
|
||||
})
|
||||
|
||||
const result = await removePrompt(mockController, request)
|
||||
|
||||
assert.strictEqual(result.value, false)
|
||||
})
|
||||
|
||||
it("should return false when file deletion fails (permission denied)", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
fsUnlinkStub.rejects(new Error("EACCES: permission denied"))
|
||||
|
||||
const request = RemovePromptRequest.create({
|
||||
promptId: "test",
|
||||
type: 1,
|
||||
name: "Test",
|
||||
})
|
||||
|
||||
const result = await removePrompt(mockController, request)
|
||||
|
||||
assert.strictEqual(result.value, false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import * as fs from "node:fs/promises"
|
||||
import * as path from "node:path"
|
||||
import type { Boolean } from "@shared/proto/cline/common"
|
||||
import type { ApplyPromptRequest } from "@shared/proto/cline/prompts"
|
||||
import axios from "axios"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { getWorkspacePath } from "@/utils/path"
|
||||
import type { Controller } from ".."
|
||||
|
||||
const RAW_CONTENT_BASE = "https://raw.githubusercontent.com/cline/prompts/main"
|
||||
|
||||
/**
|
||||
* Maps a proto PromptType number to the target directory and file structure
|
||||
* within the workspace.
|
||||
*
|
||||
* Prompt types and their filesystem locations:
|
||||
* - RULE (1): .clinerules/{name}.md
|
||||
* - WORKFLOW (2): .clinerules/workflows/{name}.md
|
||||
* - HOOK (3): .clinerules/hooks/{name}.md
|
||||
* - SKILL (4): .clinerules/skills/{name}/SKILL.md
|
||||
*/
|
||||
function getTargetPath(cwd: string, type: number, fileName: string): { directory: string; filePath: string } {
|
||||
switch (type) {
|
||||
case 2: // PROMPT_TYPE_WORKFLOW
|
||||
return {
|
||||
directory: path.join(cwd, ".clinerules", "workflows"),
|
||||
filePath: path.join(cwd, ".clinerules", "workflows", fileName + ".md"),
|
||||
}
|
||||
case 3: // PROMPT_TYPE_HOOK
|
||||
return {
|
||||
directory: path.join(cwd, ".clinerules", "hooks"),
|
||||
filePath: path.join(cwd, ".clinerules", "hooks", fileName + ".md"),
|
||||
}
|
||||
case 4: {
|
||||
// PROMPT_TYPE_SKILL - skills use a directory with SKILL.md inside
|
||||
const skillDir = path.join(cwd, ".clinerules", "skills", fileName)
|
||||
return {
|
||||
directory: skillDir,
|
||||
filePath: path.join(skillDir, "SKILL.md"),
|
||||
}
|
||||
}
|
||||
case 1: // PROMPT_TYPE_RULE
|
||||
default:
|
||||
return {
|
||||
directory: path.join(cwd, ".clinerules"),
|
||||
filePath: path.join(cwd, ".clinerules", fileName + ".md"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a proto PromptType number to the source directory path in the prompts repo.
|
||||
*/
|
||||
function getRepoPath(type: number, promptId: string): string {
|
||||
switch (type) {
|
||||
case 2: // PROMPT_TYPE_WORKFLOW
|
||||
return `workflows/${promptId}.md`
|
||||
case 3: // PROMPT_TYPE_HOOK
|
||||
return `hooks/${promptId}.md`
|
||||
case 4: // PROMPT_TYPE_SKILL
|
||||
return `skills/${promptId}.md`
|
||||
case 1: // PROMPT_TYPE_RULE
|
||||
default:
|
||||
return `.clinerules/${promptId}.md`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches prompt content from raw.githubusercontent.com (CDN, not rate-limited).
|
||||
* Used when the catalog doesn't include file content (centralized API pattern).
|
||||
*/
|
||||
async function fetchPromptContent(type: number, promptId: string): Promise<string> {
|
||||
const repoPath = getRepoPath(type, promptId)
|
||||
const url = `${RAW_CONTENT_BASE}/${repoPath}`
|
||||
const response = await axios.get(url, { ...getAxiosSettings(), timeout: 10_000 })
|
||||
return typeof response.data === "string" ? response.data : String(response.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a prompt to the workspace by writing it to the appropriate directory.
|
||||
* If content is empty (catalog didn't include it), fetches it on-demand from GitHub CDN.
|
||||
*/
|
||||
export async function applyPrompt(_controller: Controller, request: ApplyPromptRequest): Promise<Boolean> {
|
||||
try {
|
||||
const { promptId, type, name } = request
|
||||
let { content } = request
|
||||
|
||||
// Get workspace root
|
||||
const cwd = await getWorkspacePath()
|
||||
if (!cwd) {
|
||||
Logger.error("No workspace root available")
|
||||
return { value: false }
|
||||
}
|
||||
|
||||
// Fetch content on-demand if not provided (centralized API doesn't include file content)
|
||||
if (!content) {
|
||||
try {
|
||||
content = await fetchPromptContent(type, promptId)
|
||||
} catch (error) {
|
||||
Logger.error(`Error fetching prompt content for ${promptId}:`, error)
|
||||
return { value: false }
|
||||
}
|
||||
}
|
||||
|
||||
// Create kebab-case filename from prompt name or ID
|
||||
const fileName = (name || promptId)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
|
||||
const { directory, filePath } = getTargetPath(cwd, type, fileName)
|
||||
|
||||
// Ensure directory exists
|
||||
try {
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
} catch (error) {
|
||||
Logger.error(`Error creating directory ${directory}:`, error)
|
||||
return { value: false }
|
||||
}
|
||||
|
||||
// Write the file
|
||||
try {
|
||||
await fs.writeFile(filePath, content, "utf-8")
|
||||
Logger.info(`Successfully wrote prompt to ${filePath}`)
|
||||
return { value: true }
|
||||
} catch (error) {
|
||||
Logger.error(`Error writing file ${filePath}:`, error)
|
||||
return { value: false }
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Error in applyPrompt:", error)
|
||||
return { value: false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import type { PromptsCatalog } from "@shared/proto/cline/prompts"
|
||||
import { convertStringToProtoPromptType } from "@shared/proto-conversions/prompts/prompt-conversion"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Fetches the prompts catalog from the PromptsService
|
||||
*/
|
||||
export async function fetchPromptsCatalog(controller: Controller, _request: EmptyRequest): Promise<PromptsCatalog> {
|
||||
try {
|
||||
// Fetch catalog from PromptsService
|
||||
const catalog = await controller.promptsService.fetchPromptsCatalog()
|
||||
|
||||
return {
|
||||
items: catalog.items.map((item) => ({
|
||||
promptId: item.promptId,
|
||||
githubUrl: item.githubUrl,
|
||||
name: item.name,
|
||||
author: item.author,
|
||||
description: item.description,
|
||||
category: item.category,
|
||||
tags: item.tags,
|
||||
type: convertStringToProtoPromptType(item.type),
|
||||
content: item.content,
|
||||
version: item.version || "",
|
||||
globs: item.globs || [],
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
})),
|
||||
lastUpdated: catalog.lastUpdated,
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Error in fetchPromptsCatalog:", error)
|
||||
// Return empty catalog on error
|
||||
return {
|
||||
items: [],
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as fs from "node:fs/promises"
|
||||
import * as path from "node:path"
|
||||
import { type EmptyRequest, StringArray } from "@shared/proto/cline/common"
|
||||
import { getWorkspacePath } from "@/utils/path"
|
||||
import type { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Reads .md filenames (without extension) from a directory.
|
||||
* Returns an empty array if the directory doesn't exist or can't be read.
|
||||
*/
|
||||
async function readMdFileIds(dirPath: string): Promise<string[]> {
|
||||
try {
|
||||
const files = await fs.readdir(dirPath)
|
||||
return files.filter((f) => f.endsWith(".md")).map((f) => f.replace(".md", ""))
|
||||
} catch {
|
||||
// Directory doesn't exist or can't be read, skip
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads skill directory names that contain a SKILL.md file.
|
||||
* Returns an empty array if the directory doesn't exist or can't be read.
|
||||
*/
|
||||
async function readSkillIds(skillsDir: string): Promise<string[]> {
|
||||
try {
|
||||
const entries = await fs.readdir(skillsDir)
|
||||
const ids: string[] = []
|
||||
for (const entry of entries) {
|
||||
const skillMdPath = path.join(skillsDir, entry, "SKILL.md")
|
||||
try {
|
||||
const stat = await fs.stat(skillMdPath)
|
||||
if (stat.isFile()) {
|
||||
ids.push(entry)
|
||||
}
|
||||
} catch {
|
||||
// No SKILL.md in this entry, skip
|
||||
}
|
||||
}
|
||||
return ids
|
||||
} catch {
|
||||
// Directory doesn't exist or can't be read, skip
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of currently applied prompt IDs by scanning workspace directories.
|
||||
*
|
||||
* Scans:
|
||||
* - .clinerules/ → rules (top-level .md files, excluding subdirectories)
|
||||
* - .clinerules/workflows → workflows
|
||||
* - .clinerules/hooks → hooks
|
||||
* - .clinerules/skills → skills (directories containing SKILL.md)
|
||||
*/
|
||||
export async function getAppliedPrompts(_controller: Controller, _request: EmptyRequest): Promise<StringArray> {
|
||||
try {
|
||||
const workspaceRoot = await getWorkspacePath()
|
||||
if (!workspaceRoot) {
|
||||
return { values: [] }
|
||||
}
|
||||
|
||||
const clinerulesDir = path.join(workspaceRoot, ".clinerules")
|
||||
|
||||
// Scan all four directories in parallel
|
||||
const [ruleIds, workflowIds, hookIds, skillIds] = await Promise.all([
|
||||
readMdFileIds(clinerulesDir),
|
||||
readMdFileIds(path.join(clinerulesDir, "workflows")),
|
||||
readMdFileIds(path.join(clinerulesDir, "hooks")),
|
||||
readSkillIds(path.join(clinerulesDir, "skills")),
|
||||
])
|
||||
|
||||
// Prefix each ID with its type to avoid collisions between types
|
||||
// e.g. a rule and workflow both named "test" become "rule:test" and "workflow:test"
|
||||
const appliedPromptIds = [
|
||||
...ruleIds.map((id) => `rule:${id}`),
|
||||
...workflowIds.map((id) => `workflow:${id}`),
|
||||
...hookIds.map((id) => `hook:${id}`),
|
||||
...skillIds.map((id) => `skill:${id}`),
|
||||
]
|
||||
|
||||
return StringArray.create({ values: appliedPromptIds })
|
||||
} catch {
|
||||
// Silently handle errors and return empty array
|
||||
return StringArray.create({ values: [] })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as fs from "node:fs/promises"
|
||||
import * as path from "node:path"
|
||||
import type { Boolean } from "@shared/proto/cline/common"
|
||||
import type { RemovePromptRequest } from "@shared/proto/cline/prompts"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { getWorkspacePath } from "@/utils/path"
|
||||
import type { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Returns the filesystem path for a prompt based on its type.
|
||||
*
|
||||
* Prompt types and their filesystem locations:
|
||||
* - RULE (1): .clinerules/{name}.md
|
||||
* - WORKFLOW (2): .clinerules/workflows/{name}.md
|
||||
* - HOOK (3): .clinerules/hooks/{name}.md
|
||||
* - SKILL (4): .clinerules/skills/{name}/SKILL.md
|
||||
*/
|
||||
function getTargetPath(cwd: string, type: number, fileName: string): string {
|
||||
switch (type) {
|
||||
case 2: // PROMPT_TYPE_WORKFLOW
|
||||
return path.join(cwd, ".clinerules", "workflows", fileName + ".md")
|
||||
case 3: // PROMPT_TYPE_HOOK
|
||||
return path.join(cwd, ".clinerules", "hooks", fileName + ".md")
|
||||
case 4: // PROMPT_TYPE_SKILL
|
||||
return path.join(cwd, ".clinerules", "skills", fileName, "SKILL.md")
|
||||
case 1: // PROMPT_TYPE_RULE
|
||||
default:
|
||||
return path.join(cwd, ".clinerules", fileName + ".md")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a prompt from the workspace by deleting it from the appropriate directory
|
||||
*/
|
||||
export async function removePrompt(_controller: Controller, request: RemovePromptRequest): Promise<Boolean> {
|
||||
try {
|
||||
const { promptId, type, name } = request
|
||||
|
||||
// Get workspace root
|
||||
const cwd = await getWorkspacePath()
|
||||
if (!cwd) {
|
||||
Logger.error("No workspace root available")
|
||||
return { value: false }
|
||||
}
|
||||
|
||||
// Create kebab-case filename from prompt name or ID
|
||||
const fileName = (name || promptId)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
|
||||
const targetPath = getTargetPath(cwd, type, fileName)
|
||||
|
||||
// Delete the file
|
||||
try {
|
||||
await fs.unlink(targetPath)
|
||||
Logger.info(`Successfully removed prompt from ${targetPath}`)
|
||||
|
||||
// For skills, also try to remove the now-empty skill directory
|
||||
if (type === 4) {
|
||||
const skillDir = path.dirname(targetPath)
|
||||
try {
|
||||
await fs.rmdir(skillDir) // Only removes if empty
|
||||
} catch {
|
||||
// Directory not empty or doesn't exist, ignore
|
||||
}
|
||||
}
|
||||
|
||||
return { value: true }
|
||||
} catch (error) {
|
||||
// File might not exist, log but don't fail completely
|
||||
Logger.error(`Error removing file ${targetPath}:`, error)
|
||||
return { value: false }
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Error in removePrompt:", error)
|
||||
return { value: false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import type { PromptsCatalog } from "@shared/proto/cline/prompts"
|
||||
import { convertStringToProtoPromptType } from "@shared/proto-conversions/prompts/prompt-conversion"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { Controller } from ".."
|
||||
import type { StreamingResponseHandler } from "../grpc-handler"
|
||||
|
||||
/**
|
||||
* Subscribes to prompts catalog updates
|
||||
*/
|
||||
export async function subscribeToPromptsCatalog(
|
||||
controller: Controller,
|
||||
_request: EmptyRequest,
|
||||
responseStream: StreamingResponseHandler<PromptsCatalog>,
|
||||
_requestId?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Fetch initial catalog
|
||||
const catalog = await controller.promptsService.fetchPromptsCatalog()
|
||||
|
||||
// Send initial catalog
|
||||
await responseStream(
|
||||
{
|
||||
items: catalog.items.map((item) => ({
|
||||
promptId: item.promptId,
|
||||
githubUrl: item.githubUrl,
|
||||
name: item.name,
|
||||
author: item.author,
|
||||
description: item.description,
|
||||
category: item.category,
|
||||
tags: item.tags,
|
||||
type: convertStringToProtoPromptType(item.type),
|
||||
content: item.content,
|
||||
version: item.version || "",
|
||||
globs: item.globs || [],
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
})),
|
||||
lastUpdated: catalog.lastUpdated,
|
||||
},
|
||||
false, // Not the last message
|
||||
)
|
||||
|
||||
// TODO: Set up file watcher for prompts directory and stream updates
|
||||
} catch (error) {
|
||||
Logger.error("Error fetching prompts catalog:", error)
|
||||
// Return empty catalog on error
|
||||
await responseStream(
|
||||
{
|
||||
items: [],
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
true, // Last message
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { getRequestRegistry, StreamingResponseHandler } from "../grpc-handler"
|
||||
import { Controller } from "../index"
|
||||
|
||||
// Keep track of active promptsButtonClicked subscriptions
|
||||
const activePromptsButtonClickedSubscriptions = new Set<StreamingResponseHandler<Empty>>()
|
||||
|
||||
/**
|
||||
* Subscribe to promptsButtonClicked events
|
||||
* @param controller The controller instance
|
||||
* @param request The empty request
|
||||
* @param responseStream The streaming response handler
|
||||
* @param requestId The ID of the request (passed by the gRPC handler)
|
||||
*/
|
||||
export async function subscribeToPromptsButtonClicked(
|
||||
_controller: Controller,
|
||||
_request: EmptyRequest,
|
||||
responseStream: StreamingResponseHandler<Empty>,
|
||||
requestId?: string,
|
||||
): Promise<void> {
|
||||
// Add this subscription to the active subscriptions
|
||||
activePromptsButtonClickedSubscriptions.add(responseStream)
|
||||
|
||||
// Register cleanup when the connection is closed
|
||||
const cleanup = () => {
|
||||
activePromptsButtonClickedSubscriptions.delete(responseStream)
|
||||
}
|
||||
|
||||
// Register the cleanup function with the request registry if we have a requestId
|
||||
if (requestId) {
|
||||
getRequestRegistry().registerRequest(requestId, cleanup, { type: "promptsButtonClicked_subscription" }, responseStream)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a promptsButtonClicked event to all active subscribers
|
||||
*/
|
||||
export async function sendPromptsButtonClickedEvent(): Promise<void> {
|
||||
// Send the event to all active subscribers
|
||||
const promises = Array.from(activePromptsButtonClickedSubscriptions).map(async (responseStream) => {
|
||||
try {
|
||||
const event = Empty.create({})
|
||||
await responseStream(
|
||||
event,
|
||||
false, // Not the last message
|
||||
)
|
||||
} catch (error) {
|
||||
Logger.error("Error sending promptsButtonClicked event:", error)
|
||||
// Remove the subscription if there was an error
|
||||
activePromptsButtonClickedSubscriptions.delete(responseStream)
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
}
|
||||
@@ -90,7 +90,7 @@ export class UseMcpToolHandler implements IFullyManagedTool {
|
||||
?.find((conn: any) => conn.server.name === server_name)
|
||||
?.server.tools?.find((tool: any) => tool.name === tool_name)?.autoApprove
|
||||
|
||||
if (config.callbacks.shouldAutoApproveTool(block.name) || isToolAutoApproved) {
|
||||
if (config.callbacks.shouldAutoApproveTool(block.name) && isToolAutoApproved) {
|
||||
// Auto-approval flow
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "use_mcp_server")
|
||||
await config.callbacks.say("use_mcp_server", completeMessage, undefined, undefined, false)
|
||||
@@ -128,18 +128,17 @@ export class UseMcpToolHandler implements IFullyManagedTool {
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
return formatResponse.toolDenied()
|
||||
} else {
|
||||
telemetryService.captureToolUsage(
|
||||
config.ulid,
|
||||
block.name,
|
||||
config.api.getModel().id,
|
||||
provider,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
}
|
||||
telemetryService.captureToolUsage(
|
||||
config.ulid,
|
||||
block.name,
|
||||
config.api.getModel().id,
|
||||
provider,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
}
|
||||
|
||||
// Run PreToolUse hook after approval but before execution
|
||||
|
||||
@@ -9,6 +9,7 @@ import { sendAccountButtonClickedEvent } from "./core/controller/ui/subscribeToA
|
||||
import { sendChatButtonClickedEvent } from "./core/controller/ui/subscribeToChatButtonClicked"
|
||||
import { sendHistoryButtonClickedEvent } from "./core/controller/ui/subscribeToHistoryButtonClicked"
|
||||
import { sendMcpButtonClickedEvent } from "./core/controller/ui/subscribeToMcpButtonClicked"
|
||||
import { sendPromptsButtonClickedEvent } from "./core/controller/ui/subscribeToPromptsButtonClicked"
|
||||
import { sendSettingsButtonClickedEvent } from "./core/controller/ui/subscribeToSettingsButtonClicked"
|
||||
import { sendWorktreesButtonClickedEvent } from "./core/controller/ui/subscribeToWorktreesButtonClicked"
|
||||
import { WebviewProvider } from "./core/webview"
|
||||
@@ -132,6 +133,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
await sendChatButtonClickedEvent()
|
||||
}),
|
||||
)
|
||||
context.subscriptions.push(vscode.commands.registerCommand(commands.PromptsButton, () => sendPromptsButtonClickedEvent()))
|
||||
context.subscriptions.push(vscode.commands.registerCommand(commands.McpButton, () => sendMcpButtonClickedEvent()))
|
||||
context.subscriptions.push(vscode.commands.registerCommand(commands.SettingsButton, () => sendSettingsButtonClickedEvent()))
|
||||
context.subscriptions.push(vscode.commands.registerCommand(commands.HistoryButton, () => sendHistoryButtonClickedEvent()))
|
||||
|
||||
@@ -11,6 +11,7 @@ const prefix = name === "claude-dev" ? "cline" : name
|
||||
*/
|
||||
const ClineCommands = {
|
||||
PlusButton: prefix + ".plusButtonClicked",
|
||||
PromptsButton: prefix + ".promptsButtonClicked",
|
||||
McpButton: prefix + ".mcpButtonClicked",
|
||||
SettingsButton: prefix + ".settingsButtonClicked",
|
||||
HistoryButton: prefix + ".historyButtonClicked",
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import axios from "axios"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import type { PromptItem, PromptsCatalog } from "@/shared/prompts"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
const GITHUB_API_BASE = "https://api.github.com"
|
||||
const RAW_CONTENT_BASE = "https://raw.githubusercontent.com/cline/prompts/main"
|
||||
const REPO_OWNER = "cline"
|
||||
const REPO_NAME = "prompts"
|
||||
|
||||
// Maps repo directory prefixes to prompt types
|
||||
const DIRECTORY_TYPE_MAP: Record<string, "rule" | "workflow" | "hook" | "skill"> = {
|
||||
".clinerules/": "rule",
|
||||
"workflows/": "workflow",
|
||||
"hooks/": "hook",
|
||||
"skills/": "skill",
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal YAML frontmatter parser.
|
||||
* Extracts key-value pairs from YAML frontmatter delimited by `---`.
|
||||
* Handles strings, numbers, and simple arrays like ["a", "b"].
|
||||
*/
|
||||
function parseFrontmatter(content: string): Record<string, unknown> {
|
||||
const match = content.match(/^---\s*\n([\s\S]*?)\n---/)
|
||||
if (!match) return {}
|
||||
|
||||
const yamlBlock = match[1]
|
||||
const result: Record<string, unknown> = {}
|
||||
|
||||
for (const line of yamlBlock.split("\n")) {
|
||||
const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)$/)
|
||||
if (!kvMatch) continue
|
||||
|
||||
const key = kvMatch[1]
|
||||
let value: unknown = kvMatch[2].trim()
|
||||
|
||||
// Parse arrays: ["tag1", "tag2"]
|
||||
if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) {
|
||||
try {
|
||||
value = JSON.parse(value)
|
||||
} catch {
|
||||
// Try parsing as YAML-style array
|
||||
value = (value as string)
|
||||
.slice(1, -1)
|
||||
.split(",")
|
||||
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
// Strip surrounding quotes
|
||||
else if (typeof value === "string" && /^["'].*["']$/.test(value)) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
|
||||
result[key] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves author name from a string that might be a GitHub URL.
|
||||
*/
|
||||
function resolveAuthorName(author: string): string {
|
||||
try {
|
||||
const url = new URL(author.startsWith("http") ? author : `https://${author}`)
|
||||
if (url.hostname === "github.com" || url.hostname === "www.github.com") {
|
||||
const segments = url.pathname.split("/").filter(Boolean)
|
||||
if (segments.length > 0) return segments[0]
|
||||
}
|
||||
} catch {
|
||||
// Not a URL, use as-is
|
||||
}
|
||||
return author
|
||||
}
|
||||
|
||||
interface GitTreeEntry {
|
||||
path: string
|
||||
mode: string
|
||||
type: string
|
||||
sha: string
|
||||
url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for fetching and managing prompts from the cline/prompts GitHub repository.
|
||||
*
|
||||
* Uses the Git Tree API (1 rate-limited call) to discover all files, then fetches
|
||||
* raw content from the CDN (not rate-limited) to parse YAML frontmatter for metadata.
|
||||
*/
|
||||
export class PromptsService {
|
||||
private cachedCatalog: PromptsCatalog | null = null
|
||||
private lastFetchTime = 0
|
||||
private readonly CACHE_DURATION = 60 * 60 * 1000 // 1 hour
|
||||
private cachedGitHubToken: string | null | undefined = undefined // undefined = not yet resolved
|
||||
|
||||
/**
|
||||
* Resolves a GitHub token for authenticated API requests.
|
||||
* Authenticated requests get 5,000 req/hour vs 60 for unauthenticated.
|
||||
* Checks environment variables first, then falls back to `gh auth token`.
|
||||
* Caches the result (including null for "no token available").
|
||||
* Protected to allow test stubbing.
|
||||
*/
|
||||
protected resolveGitHubToken(): string | null {
|
||||
if (this.cachedGitHubToken !== undefined) {
|
||||
return this.cachedGitHubToken
|
||||
}
|
||||
|
||||
// Check environment variables
|
||||
const envToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN
|
||||
if (envToken) {
|
||||
this.cachedGitHubToken = envToken
|
||||
Logger.info("PromptsService: Using GitHub token from environment variable")
|
||||
return envToken
|
||||
}
|
||||
|
||||
// Try `gh auth token` (GitHub CLI)
|
||||
try {
|
||||
const { execSync } = require("child_process")
|
||||
const token = execSync("gh auth token", {
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim()
|
||||
if (token) {
|
||||
this.cachedGitHubToken = token
|
||||
Logger.info("PromptsService: Using GitHub token from gh CLI")
|
||||
return token
|
||||
}
|
||||
} catch {
|
||||
// gh CLI not available or not authenticated
|
||||
}
|
||||
|
||||
this.cachedGitHubToken = null
|
||||
Logger.info("PromptsService: No GitHub token available, using unauthenticated requests (60 req/hour)")
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for HTTP GET requests. Protected to allow test stubbing.
|
||||
*/
|
||||
protected async httpGet(url: string, headers?: Record<string, string>) {
|
||||
return axios.get(url, {
|
||||
...getAxiosSettings(),
|
||||
timeout: 15_000,
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
...headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches raw file content from the GitHub CDN (not rate-limited).
|
||||
* Protected to allow test stubbing.
|
||||
*/
|
||||
protected async fetchRawContent(filePath: string): Promise<string> {
|
||||
const url = `${RAW_CONTENT_BASE}/${filePath}`
|
||||
const response = await axios.get(url, {
|
||||
...getAxiosSettings(),
|
||||
timeout: 10_000,
|
||||
responseType: "text",
|
||||
})
|
||||
return typeof response.data === "string" ? response.data : String(response.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the prompts catalog from the cline/prompts GitHub repository.
|
||||
*
|
||||
* 1. Uses the Git Tree API (1 rate-limited call) to list all files
|
||||
* 2. Fetches raw content from CDN (not rate-limited) for each markdown file
|
||||
* 3. Parses YAML frontmatter for metadata (author, version, description, etc.)
|
||||
*/
|
||||
async fetchPromptsCatalog(): Promise<PromptsCatalog> {
|
||||
// Return cached catalog if still fresh
|
||||
const now = Date.now()
|
||||
if (this.cachedCatalog && now - this.lastFetchTime < this.CACHE_DURATION) {
|
||||
return this.cachedCatalog
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Get all files via Git Tree API (single rate-limited call)
|
||||
// Use authenticated request if a GitHub token is available (5,000 req/hour vs 60)
|
||||
const treeUrl = `${GITHUB_API_BASE}/repos/${REPO_OWNER}/${REPO_NAME}/git/trees/main?recursive=1`
|
||||
const token = this.resolveGitHubToken()
|
||||
const authHeaders: Record<string, string> = {}
|
||||
if (token) {
|
||||
authHeaders.Authorization = `token ${token}`
|
||||
}
|
||||
const treeResponse = await this.httpGet(treeUrl, authHeaders)
|
||||
const entries: GitTreeEntry[] = treeResponse.data?.tree || []
|
||||
|
||||
// Filter to markdown files in known directories
|
||||
const markdownFiles = entries.filter((entry) => {
|
||||
if (entry.type !== "blob" || !entry.path.toLowerCase().endsWith(".md")) return false
|
||||
return Object.keys(DIRECTORY_TYPE_MAP).some((prefix) => entry.path.startsWith(prefix))
|
||||
})
|
||||
|
||||
// Step 2: Fetch raw content from CDN and parse frontmatter (parallel, not rate-limited)
|
||||
const items = await Promise.all(
|
||||
markdownFiles.map(async (entry) => {
|
||||
try {
|
||||
return await this.processFile(entry.path)
|
||||
} catch (error) {
|
||||
Logger.error(`Error processing ${entry.path}:`, error)
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const catalog: PromptsCatalog = {
|
||||
items: items.filter((item): item is PromptItem => item !== null),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
this.cachedCatalog = catalog
|
||||
this.lastFetchTime = now
|
||||
|
||||
return catalog
|
||||
} catch (error) {
|
||||
Logger.error("Error in fetchPromptsCatalog:", error)
|
||||
return {
|
||||
items: [],
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a single file: fetches content from CDN and parses frontmatter.
|
||||
*/
|
||||
private async processFile(filePath: string): Promise<PromptItem | null> {
|
||||
// Determine prompt type from directory
|
||||
let promptType: "rule" | "workflow" | "hook" | "skill" | null = null
|
||||
for (const [prefix, type] of Object.entries(DIRECTORY_TYPE_MAP)) {
|
||||
if (filePath.startsWith(prefix)) {
|
||||
promptType = type
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!promptType) return null
|
||||
|
||||
// Fetch raw content from CDN (not rate-limited)
|
||||
const content = await this.fetchRawContent(filePath)
|
||||
|
||||
// Parse YAML frontmatter
|
||||
const frontmatter = parseFrontmatter(content)
|
||||
|
||||
const fileName = filePath.split("/").pop() || ""
|
||||
const promptId = fileName.replace(/\.md$/, "")
|
||||
|
||||
// Resolve author
|
||||
let authorName = "Unknown"
|
||||
const fmAuthor = typeof frontmatter.author === "string" ? frontmatter.author.trim() : ""
|
||||
if (fmAuthor) {
|
||||
authorName = resolveAuthorName(fmAuthor)
|
||||
}
|
||||
|
||||
// Resolve version
|
||||
const version = frontmatter.version != null ? String(frontmatter.version).trim() : ""
|
||||
|
||||
return {
|
||||
promptId,
|
||||
githubUrl: `https://github.com/${REPO_OWNER}/${REPO_NAME}/blob/main/${filePath}`,
|
||||
name: promptId.replace(/-/g, " ").replace(/\b\w/g, (l: string) => l.toUpperCase()),
|
||||
author: authorName,
|
||||
description:
|
||||
typeof frontmatter.description === "string" && frontmatter.description.trim()
|
||||
? frontmatter.description.trim()
|
||||
: "No description available",
|
||||
category:
|
||||
typeof frontmatter.category === "string" && frontmatter.category.trim() ? frontmatter.category.trim() : "General",
|
||||
tags: Array.isArray(frontmatter.tags) ? frontmatter.tags.map(String) : [],
|
||||
type: promptType,
|
||||
content, // Include full content for apply
|
||||
version,
|
||||
globs: Array.isArray(frontmatter.globs) ? frontmatter.globs.map(String) : [],
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import * as assert from "assert"
|
||||
import * as sinon from "sinon"
|
||||
import { PromptsService } from "@/services/prompts/PromptsService"
|
||||
|
||||
/**
|
||||
* Helper: builds a mock Git Tree API response.
|
||||
*/
|
||||
function mockTreeResponse(entries: Array<{ path: string; type?: string }>) {
|
||||
return {
|
||||
data: {
|
||||
sha: "abc123",
|
||||
url: "https://api.github.com/repos/cline/prompts/git/trees/main",
|
||||
tree: entries.map((e) => ({
|
||||
path: e.path,
|
||||
mode: "100644",
|
||||
type: e.type || "blob",
|
||||
sha: "def456",
|
||||
url: `https://api.github.com/repos/cline/prompts/git/blobs/def456`,
|
||||
})),
|
||||
truncated: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: builds markdown content with YAML frontmatter.
|
||||
*/
|
||||
function makeMarkdown(frontmatter: Record<string, unknown>, body = "# Content"): string {
|
||||
const yamlLines: string[] = []
|
||||
for (const [key, value] of Object.entries(frontmatter)) {
|
||||
if (Array.isArray(value)) {
|
||||
yamlLines.push(`${key}: ${JSON.stringify(value)}`)
|
||||
} else if (typeof value === "string" && value.includes('"')) {
|
||||
yamlLines.push(`${key}: '${value}'`)
|
||||
} else {
|
||||
yamlLines.push(`${key}: ${value}`)
|
||||
}
|
||||
}
|
||||
return `---\n${yamlLines.join("\n")}\n---\n${body}`
|
||||
}
|
||||
|
||||
describe("PromptsService", () => {
|
||||
let service: PromptsService
|
||||
let httpGetStub: sinon.SinonStub
|
||||
let fetchRawContentStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PromptsService()
|
||||
httpGetStub = sinon.stub(service as any, "httpGet")
|
||||
fetchRawContentStub = sinon.stub(service as any, "fetchRawContent")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe("fetchPromptsCatalog", () => {
|
||||
describe("Frontmatter Parsing", () => {
|
||||
it("should parse author, version, and description from frontmatter", async () => {
|
||||
httpGetStub.resolves(mockTreeResponse([{ path: ".clinerules/test-prompt.md" }]))
|
||||
fetchRawContentStub.resolves(
|
||||
makeMarkdown({
|
||||
description: "Test description",
|
||||
author: "testuser",
|
||||
version: "1.0",
|
||||
category: "Testing",
|
||||
tags: ["tag1", "tag2"],
|
||||
}),
|
||||
)
|
||||
|
||||
const catalog = await service.fetchPromptsCatalog()
|
||||
|
||||
assert.strictEqual(catalog.items.length, 1)
|
||||
assert.strictEqual(catalog.items[0].description, "Test description")
|
||||
assert.strictEqual(catalog.items[0].author, "testuser")
|
||||
assert.strictEqual(catalog.items[0].version, "1.0")
|
||||
assert.strictEqual(catalog.items[0].category, "Testing")
|
||||
assert.deepStrictEqual(catalog.items[0].tags, ["tag1", "tag2"])
|
||||
})
|
||||
|
||||
it("should extract GitHub username from author URL", async () => {
|
||||
httpGetStub.resolves(mockTreeResponse([{ path: ".clinerules/github-author.md" }]))
|
||||
fetchRawContentStub.resolves(
|
||||
makeMarkdown({
|
||||
description: "Test",
|
||||
author: "https://github.com/octocat",
|
||||
}),
|
||||
)
|
||||
|
||||
const catalog = await service.fetchPromptsCatalog()
|
||||
|
||||
assert.strictEqual(catalog.items[0].author, "octocat")
|
||||
})
|
||||
|
||||
it("should handle non-URL author string as-is", async () => {
|
||||
httpGetStub.resolves(mockTreeResponse([{ path: ".clinerules/plain-author.md" }]))
|
||||
fetchRawContentStub.resolves(
|
||||
makeMarkdown({
|
||||
description: "Test",
|
||||
author: "John Doe",
|
||||
}),
|
||||
)
|
||||
|
||||
const catalog = await service.fetchPromptsCatalog()
|
||||
|
||||
assert.strictEqual(catalog.items[0].author, "John Doe")
|
||||
})
|
||||
|
||||
it("should default author to Unknown when not in frontmatter", async () => {
|
||||
httpGetStub.resolves(mockTreeResponse([{ path: ".clinerules/no-author.md" }]))
|
||||
fetchRawContentStub.resolves(makeMarkdown({ description: "Test" }))
|
||||
|
||||
const catalog = await service.fetchPromptsCatalog()
|
||||
|
||||
assert.strictEqual(catalog.items[0].author, "Unknown")
|
||||
})
|
||||
|
||||
it("should handle numeric version in frontmatter", async () => {
|
||||
httpGetStub.resolves(mockTreeResponse([{ path: ".clinerules/numeric-ver.md" }]))
|
||||
fetchRawContentStub.resolves(makeMarkdown({ description: "Test", version: 1.1 }))
|
||||
|
||||
const catalog = await service.fetchPromptsCatalog()
|
||||
|
||||
assert.strictEqual(catalog.items[0].version, "1.1")
|
||||
})
|
||||
|
||||
it("should return empty version when not in frontmatter", async () => {
|
||||
httpGetStub.resolves(mockTreeResponse([{ path: ".clinerules/no-ver.md" }]))
|
||||
fetchRawContentStub.resolves(makeMarkdown({ description: "Test" }))
|
||||
|
||||
const catalog = await service.fetchPromptsCatalog()
|
||||
|
||||
assert.strictEqual(catalog.items[0].version, "")
|
||||
})
|
||||
|
||||
it("should default description when missing from frontmatter", async () => {
|
||||
httpGetStub.resolves(mockTreeResponse([{ path: ".clinerules/no-desc.md" }]))
|
||||
fetchRawContentStub.resolves("---\nauthor: test\n---\n# Content")
|
||||
|
||||
const catalog = await service.fetchPromptsCatalog()
|
||||
|
||||
assert.strictEqual(catalog.items[0].description, "No description available")
|
||||
})
|
||||
|
||||
it("should default category to General when missing", async () => {
|
||||
httpGetStub.resolves(mockTreeResponse([{ path: ".clinerules/no-cat.md" }]))
|
||||
fetchRawContentStub.resolves(makeMarkdown({ description: "Test" }))
|
||||
|
||||
const catalog = await service.fetchPromptsCatalog()
|
||||
|
||||
assert.strictEqual(catalog.items[0].category, "General")
|
||||
})
|
||||
|
||||
it("should handle files with no frontmatter at all", async () => {
|
||||
httpGetStub.resolves(mockTreeResponse([{ path: ".clinerules/bare.md" }]))
|
||||
fetchRawContentStub.resolves("# Just a heading\n\nSome content.")
|
||||
|
||||
const catalog = await service.fetchPromptsCatalog()
|
||||
|
||||
assert.strictEqual(catalog.items.length, 1)
|
||||
assert.strictEqual(catalog.items[0].author, "Unknown")
|
||||
assert.strictEqual(catalog.items[0].version, "")
|
||||
assert.strictEqual(catalog.items[0].description, "No description available")
|
||||
})
|
||||
|
||||
it("should include full content for apply functionality", async () => {
|
||||
const fullContent = makeMarkdown({ description: "Test" }, "# Full Body\nLine 2")
|
||||
httpGetStub.resolves(mockTreeResponse([{ path: ".clinerules/with-content.md" }]))
|
||||
fetchRawContentStub.resolves(fullContent)
|
||||
|
||||
const catalog = await service.fetchPromptsCatalog()
|
||||
|
||||
assert.strictEqual(catalog.items[0].content, fullContent)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Directory Type Mapping", () => {
|
||||
it("should map files in different directories to correct types", async () => {
|
||||
httpGetStub.resolves(
|
||||
mockTreeResponse([
|
||||
{ path: ".clinerules/rule-file.md" },
|
||||
{ path: "workflows/workflow-file.md" },
|
||||
{ path: "hooks/hook-file.md" },
|
||||
{ path: "skills/skill-file.md" },
|
||||
]),
|
||||
)
|
||||
fetchRawContentStub.resolves(makeMarkdown({ description: "Test" }))
|
||||
|
||||
const catalog = await service.fetchPromptsCatalog()
|
||||
|
||||
assert.strictEqual(catalog.items.length, 4)
|
||||
assert.strictEqual(catalog.items.find((i) => i.promptId === "rule-file")?.type, "rule")
|
||||
assert.strictEqual(catalog.items.find((i) => i.promptId === "workflow-file")?.type, "workflow")
|
||||
assert.strictEqual(catalog.items.find((i) => i.promptId === "hook-file")?.type, "hook")
|
||||
assert.strictEqual(catalog.items.find((i) => i.promptId === "skill-file")?.type, "skill")
|
||||
})
|
||||
|
||||
it("should ignore files not in known directories", async () => {
|
||||
httpGetStub.resolves(
|
||||
mockTreeResponse([
|
||||
{ path: "README.md" },
|
||||
{ path: ".clinerules/valid.md" },
|
||||
{ path: "unknown-dir/unknown.md" },
|
||||
]),
|
||||
)
|
||||
fetchRawContentStub.resolves(makeMarkdown({ description: "Test" }))
|
||||
|
||||
const catalog = await service.fetchPromptsCatalog()
|
||||
|
||||
assert.strictEqual(catalog.items.length, 1)
|
||||
assert.strictEqual(catalog.items[0].promptId, "valid")
|
||||
})
|
||||
|
||||
it("should ignore non-blob entries (directories)", async () => {
|
||||
httpGetStub.resolves(
|
||||
mockTreeResponse([
|
||||
{ path: ".clinerules", type: "tree" },
|
||||
{ path: ".clinerules/valid.md", type: "blob" },
|
||||
]),
|
||||
)
|
||||
fetchRawContentStub.resolves(makeMarkdown({ description: "Test" }))
|
||||
|
||||
const catalog = await service.fetchPromptsCatalog()
|
||||
|
||||
assert.strictEqual(catalog.items.length, 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("API Efficiency", () => {
|
||||
it("should make exactly 1 API call for tree + N CDN calls for content", async () => {
|
||||
httpGetStub.resolves(
|
||||
mockTreeResponse([
|
||||
{ path: ".clinerules/file1.md" },
|
||||
{ path: ".clinerules/file2.md" },
|
||||
{ path: "workflows/file3.md" },
|
||||
]),
|
||||
)
|
||||
fetchRawContentStub.resolves(makeMarkdown({ description: "Test" }))
|
||||
|
||||
await service.fetchPromptsCatalog()
|
||||
|
||||
assert.strictEqual(httpGetStub.callCount, 1, "Should make exactly 1 API call for the tree")
|
||||
assert.strictEqual(fetchRawContentStub.callCount, 3, "Should fetch content for each markdown file")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should return empty catalog on network error", async () => {
|
||||
httpGetStub.rejects(new Error("Network error"))
|
||||
|
||||
const catalog = await service.fetchPromptsCatalog()
|
||||
|
||||
assert.strictEqual(catalog.items.length, 0)
|
||||
assert.ok(catalog.lastUpdated)
|
||||
})
|
||||
|
||||
it("should skip individual files that fail to fetch", async () => {
|
||||
httpGetStub.resolves(mockTreeResponse([{ path: ".clinerules/good.md" }, { path: ".clinerules/bad.md" }]))
|
||||
fetchRawContentStub
|
||||
.onFirstCall()
|
||||
.resolves(makeMarkdown({ description: "Good file" }))
|
||||
.onSecondCall()
|
||||
.rejects(new Error("CDN error"))
|
||||
|
||||
const catalog = await service.fetchPromptsCatalog()
|
||||
|
||||
assert.strictEqual(catalog.items.length, 1)
|
||||
assert.strictEqual(catalog.items[0].promptId, "good")
|
||||
})
|
||||
|
||||
it("should handle empty tree response", async () => {
|
||||
httpGetStub.resolves({ data: { tree: [] } })
|
||||
|
||||
const catalog = await service.fetchPromptsCatalog()
|
||||
|
||||
assert.strictEqual(catalog.items.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("GitHub Token Authentication", () => {
|
||||
it("should pass auth header when token is available", async () => {
|
||||
// Stub resolveGitHubToken to return a token
|
||||
sinon.stub(service as any, "resolveGitHubToken").returns("test-token-123")
|
||||
|
||||
// Restore httpGet so we can verify the call args
|
||||
httpGetStub.restore()
|
||||
httpGetStub = sinon.stub(service as any, "httpGet")
|
||||
httpGetStub.resolves(mockTreeResponse([{ path: ".clinerules/test.md" }]))
|
||||
fetchRawContentStub.resolves(makeMarkdown({ description: "Test" }))
|
||||
|
||||
await service.fetchPromptsCatalog()
|
||||
|
||||
assert.ok(httpGetStub.calledOnce)
|
||||
const headers = httpGetStub.firstCall.args[1]
|
||||
assert.strictEqual(headers?.Authorization, "token test-token-123")
|
||||
})
|
||||
|
||||
it("should not pass auth header when no token is available", async () => {
|
||||
// Stub resolveGitHubToken to return null
|
||||
sinon.stub(service as any, "resolveGitHubToken").returns(null)
|
||||
|
||||
httpGetStub.restore()
|
||||
httpGetStub = sinon.stub(service as any, "httpGet")
|
||||
httpGetStub.resolves(mockTreeResponse([{ path: ".clinerules/test.md" }]))
|
||||
fetchRawContentStub.resolves(makeMarkdown({ description: "Test" }))
|
||||
|
||||
await service.fetchPromptsCatalog()
|
||||
|
||||
assert.ok(httpGetStub.calledOnce)
|
||||
const headers = httpGetStub.firstCall.args[1]
|
||||
assert.strictEqual(headers?.Authorization, undefined)
|
||||
})
|
||||
|
||||
it("should check GITHUB_TOKEN env var for token resolution", () => {
|
||||
const originalToken = process.env.GITHUB_TOKEN
|
||||
try {
|
||||
process.env.GITHUB_TOKEN = "env-token-456"
|
||||
// Reset cached token
|
||||
;(service as any).cachedGitHubToken = undefined
|
||||
|
||||
const token = (service as any).resolveGitHubToken()
|
||||
assert.strictEqual(token, "env-token-456")
|
||||
} finally {
|
||||
if (originalToken !== undefined) {
|
||||
process.env.GITHUB_TOKEN = originalToken
|
||||
} else {
|
||||
delete process.env.GITHUB_TOKEN
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("should check GH_TOKEN env var as fallback", () => {
|
||||
const originalGH = process.env.GH_TOKEN
|
||||
const originalGITHUB = process.env.GITHUB_TOKEN
|
||||
try {
|
||||
delete process.env.GITHUB_TOKEN
|
||||
process.env.GH_TOKEN = "gh-token-789"
|
||||
;(service as any).cachedGitHubToken = undefined
|
||||
|
||||
const token = (service as any).resolveGitHubToken()
|
||||
assert.strictEqual(token, "gh-token-789")
|
||||
} finally {
|
||||
if (originalGITHUB !== undefined) {
|
||||
process.env.GITHUB_TOKEN = originalGITHUB
|
||||
} else {
|
||||
delete process.env.GITHUB_TOKEN
|
||||
}
|
||||
if (originalGH !== undefined) {
|
||||
process.env.GH_TOKEN = originalGH
|
||||
} else {
|
||||
delete process.env.GH_TOKEN
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("should cache resolved token", () => {
|
||||
;(service as any).cachedGitHubToken = "cached-token"
|
||||
const token = (service as any).resolveGitHubToken()
|
||||
assert.strictEqual(token, "cached-token")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Caching Behavior", () => {
|
||||
it("should cache results after first fetch", async () => {
|
||||
httpGetStub.resolves(mockTreeResponse([{ path: ".clinerules/test.md" }]))
|
||||
fetchRawContentStub.resolves(makeMarkdown({ description: "Test" }))
|
||||
|
||||
await service.fetchPromptsCatalog()
|
||||
|
||||
httpGetStub.resetHistory()
|
||||
fetchRawContentStub.resetHistory()
|
||||
const catalog = await service.fetchPromptsCatalog()
|
||||
|
||||
assert.strictEqual(httpGetStub.callCount, 0, "Should not make API calls when cache is fresh")
|
||||
assert.strictEqual(fetchRawContentStub.callCount, 0, "Should not fetch content when cache is fresh")
|
||||
assert.strictEqual(catalog.items.length, 1)
|
||||
})
|
||||
|
||||
it("should refetch after cache expiration (1 hour)", async () => {
|
||||
httpGetStub.resolves(mockTreeResponse([{ path: ".clinerules/test.md" }]))
|
||||
fetchRawContentStub.resolves(makeMarkdown({ description: "Test" }))
|
||||
|
||||
await service.fetchPromptsCatalog()
|
||||
|
||||
// Manually expire cache
|
||||
;(service as any).lastFetchTime = Date.now() - 61 * 60 * 1000
|
||||
|
||||
httpGetStub.resetHistory()
|
||||
fetchRawContentStub.resetHistory()
|
||||
|
||||
await service.fetchPromptsCatalog()
|
||||
|
||||
assert.ok(httpGetStub.callCount > 0, "Should make API calls when cache is expired")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
+31
-4
@@ -1,11 +1,12 @@
|
||||
// Model Family enum (used by system prompt variants)
|
||||
export enum ModelFamily {
|
||||
CLAUDE = "claude",
|
||||
GPT = "gpt",
|
||||
GPT_5 = "gpt-5",
|
||||
NATIVE_GPT_5 = "gpt-5-native", // Uses native tool calling
|
||||
NATIVE_GPT_5_1 = "gpt-5-1-native", // Uses native tool calling
|
||||
NATIVE_GPT_5 = "gpt-5-native",
|
||||
NATIVE_GPT_5_1 = "gpt-5-1-native",
|
||||
GEMINI = "gemini",
|
||||
GEMINI_3 = "gemini3", // Uses native tool calling
|
||||
GEMINI_3 = "gemini3",
|
||||
QWEN = "qwen",
|
||||
GLM = "glm",
|
||||
HERMES = "hermes",
|
||||
@@ -14,5 +15,31 @@ export enum ModelFamily {
|
||||
TRINITY = "trinity",
|
||||
GENERIC = "generic",
|
||||
XS = "xs",
|
||||
NATIVE_NEXT_GEN = "native-next-gen", // Uses native tool calling
|
||||
NATIVE_NEXT_GEN = "native-next-gen",
|
||||
}
|
||||
|
||||
/**
|
||||
* Types for the Prompts Library feature
|
||||
* Defines data structures for community prompts and team prompts
|
||||
*/
|
||||
|
||||
export interface PromptItem {
|
||||
promptId: string // unique identifier (e.g., "web-developer-vanilla-stack")
|
||||
githubUrl: string // source URL in prompts repo
|
||||
name: string // display name
|
||||
author: string // author name
|
||||
description: string // short description
|
||||
category: string // e.g., "Web Development", "Python", "Workflows"
|
||||
tags: string[] // searchable tags
|
||||
type: "rule" | "workflow" | "hook" | "skill" // distinguishes content types by target directory
|
||||
content: string // the actual prompt/rule content (markdown)
|
||||
version?: string // semver version if available
|
||||
globs?: string[] // file patterns from frontmatter
|
||||
createdAt: string // ISO date string
|
||||
updatedAt: string // ISO date string
|
||||
}
|
||||
|
||||
export interface PromptsCatalog {
|
||||
items: PromptItem[]
|
||||
lastUpdated: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Conversion functions between protobuf and TypeScript types for prompts
|
||||
*/
|
||||
|
||||
import type { PromptItem, PromptsCatalog } from "@/shared/prompts"
|
||||
import type { PromptItem as ProtoPromptItem, PromptsCatalog as ProtoPromptsCatalog } from "@/shared/proto/cline/prompts"
|
||||
import { PromptType as ProtoPromptType } from "@/shared/proto/cline/prompts"
|
||||
|
||||
/**
|
||||
* Converts proto PromptType enum to TypeScript string literal
|
||||
*/
|
||||
export function convertProtoPromptTypeToString(protoType: ProtoPromptType): "rule" | "workflow" | "hook" | "skill" {
|
||||
switch (protoType) {
|
||||
case ProtoPromptType.PROMPT_TYPE_RULE:
|
||||
return "rule"
|
||||
case ProtoPromptType.PROMPT_TYPE_WORKFLOW:
|
||||
return "workflow"
|
||||
case ProtoPromptType.PROMPT_TYPE_HOOK:
|
||||
return "hook"
|
||||
case ProtoPromptType.PROMPT_TYPE_SKILL:
|
||||
return "skill"
|
||||
case ProtoPromptType.PROMPT_TYPE_UNSPECIFIED:
|
||||
default:
|
||||
return "rule" // Default fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts TypeScript string literal to proto PromptType enum
|
||||
*/
|
||||
export function convertStringToProtoPromptType(type: "rule" | "workflow" | "hook" | "skill"): ProtoPromptType {
|
||||
switch (type) {
|
||||
case "rule":
|
||||
return ProtoPromptType.PROMPT_TYPE_RULE
|
||||
case "workflow":
|
||||
return ProtoPromptType.PROMPT_TYPE_WORKFLOW
|
||||
case "hook":
|
||||
return ProtoPromptType.PROMPT_TYPE_HOOK
|
||||
case "skill":
|
||||
return ProtoPromptType.PROMPT_TYPE_SKILL
|
||||
default:
|
||||
return ProtoPromptType.PROMPT_TYPE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts proto PromptItem to TypeScript PromptItem
|
||||
*/
|
||||
export function convertProtoPromptItem(protoItem: ProtoPromptItem): PromptItem {
|
||||
return {
|
||||
promptId: protoItem.promptId,
|
||||
githubUrl: protoItem.githubUrl,
|
||||
name: protoItem.name,
|
||||
author: protoItem.author,
|
||||
description: protoItem.description,
|
||||
category: protoItem.category,
|
||||
tags: protoItem.tags,
|
||||
type: convertProtoPromptTypeToString(protoItem.type),
|
||||
content: protoItem.content,
|
||||
version: protoItem.version,
|
||||
globs: protoItem.globs,
|
||||
createdAt: protoItem.createdAt,
|
||||
updatedAt: protoItem.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts proto PromptsCatalog to TypeScript PromptsCatalog
|
||||
*/
|
||||
export function convertProtoPromptsCatalog(protoCatalog: ProtoPromptsCatalog): PromptsCatalog {
|
||||
return {
|
||||
items: protoCatalog.items.map(convertProtoPromptItem),
|
||||
lastUpdated: protoCatalog.lastUpdated,
|
||||
}
|
||||
}
|
||||
@@ -23,15 +23,19 @@ function createAdapter(client: AwsClient, endpoint: string, bucket: string): Sto
|
||||
},
|
||||
|
||||
async write(path: string, value: string): Promise<void> {
|
||||
const response = await client.fetch(`${base}/${path}`, {
|
||||
method: "PUT",
|
||||
body: value,
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to write ${path}: ${response.status}`)
|
||||
try {
|
||||
const response = await client.fetch(`${base}/${path}`, {
|
||||
method: "PUT",
|
||||
body: value,
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to write ${path}: ${response.status}`)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Error in write:", error)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -96,12 +100,12 @@ export function getStorageAdapter(settings: BlobStoreSettings): StorageAdapter |
|
||||
const adapterType = settings.adapterType
|
||||
if (adapterType === "r2") {
|
||||
return createR2Adapter(settings)
|
||||
} else if (adapterType === "s3") {
|
||||
return createS3Adapter(settings)
|
||||
} else {
|
||||
Logger.error(`[StorageAdapter] Invalid adapterType: ${adapterType}. Must be "s3" or "r2".`)
|
||||
return undefined
|
||||
}
|
||||
if (adapterType === "s3") {
|
||||
return createS3Adapter(settings)
|
||||
}
|
||||
Logger.error(`[StorageAdapter] Invalid adapterType: ${adapterType}. Must be "s3" or "r2".`)
|
||||
return undefined
|
||||
} catch (error) {
|
||||
Logger.error("[StorageAdapter] Unexpected error creating adapter:", error)
|
||||
return undefined
|
||||
|
||||
@@ -92,6 +92,9 @@ const GLOBAL_STATE_FIELDS = {
|
||||
dismissedBanners: { default: [] as Array<{ bannerId: string; dismissedAt: number }> },
|
||||
// Path to worktree that should auto-open Cline sidebar when launched
|
||||
worktreeAutoOpenPath: { default: undefined as string | undefined },
|
||||
// Prompts library catalog and cache timestamp
|
||||
promptsCatalog: { default: undefined as any | undefined },
|
||||
promptsCatalogTimestamp: { default: undefined as number | undefined },
|
||||
} satisfies FieldDefinitions
|
||||
|
||||
// Fields that map directly to ApiHandlerOptions in @shared/api.ts
|
||||
|
||||
@@ -6,6 +6,7 @@ import ClineKanbanLaunchModal, { CLINE_KANBAN_MODAL_DISMISS_ID } from "./compone
|
||||
import HistoryView from "./components/history/HistoryView"
|
||||
import McpView from "./components/mcp/configuration/McpConfigurationView"
|
||||
import OnboardingView from "./components/onboarding/OnboardingView"
|
||||
import PromptsLibraryView from "./components/prompts/PromptsLibraryView"
|
||||
import SettingsView from "./components/settings/SettingsView"
|
||||
import WelcomeView from "./components/welcome/WelcomeView"
|
||||
import WorktreesView from "./components/worktrees/WorktreesView"
|
||||
@@ -27,6 +28,7 @@ const AppContent = () => {
|
||||
showHistory,
|
||||
showAccount,
|
||||
showWorktrees,
|
||||
showPrompts,
|
||||
showAnnouncement,
|
||||
onboardingModels,
|
||||
setShowAnnouncement,
|
||||
@@ -37,6 +39,7 @@ const AppContent = () => {
|
||||
hideHistory,
|
||||
hideAccount,
|
||||
hideWorktrees,
|
||||
hidePrompts,
|
||||
hideAnnouncement,
|
||||
} = useExtensionState()
|
||||
const [showKanbanModal, setShowKanbanModal] = useState(false)
|
||||
@@ -109,6 +112,7 @@ const AppContent = () => {
|
||||
{showSettings && <SettingsView onDone={hideSettings} targetSection={settingsTargetSection} />}
|
||||
{showHistory && <HistoryView onDone={hideHistory} />}
|
||||
{showMcp && <McpView initialTab={mcpTab} onDone={closeMcpView} />}
|
||||
{showPrompts && <PromptsLibraryView onDone={hidePrompts} />}
|
||||
{showAccount && (
|
||||
<AccountView
|
||||
activeOrganization={activeOrganization}
|
||||
@@ -121,7 +125,7 @@ const AppContent = () => {
|
||||
{/* Do not conditionally load ChatView, it's expensive and there's state we don't want to lose (user input, disableInput, askResponse promise, etc.) */}
|
||||
<ChatView
|
||||
hideAnnouncement={hideAnnouncement}
|
||||
isHidden={showSettings || showHistory || showMcp || showAccount || showWorktrees}
|
||||
isHidden={showSettings || showHistory || showMcp || showPrompts || showAccount || showWorktrees}
|
||||
showAnnouncement={showAnnouncement}
|
||||
showHistoryView={navigateToHistory}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@ export const AccountWelcomeView = () => {
|
||||
const { isLoginLoading, handleSignIn } = useClineSignIn()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2.5">
|
||||
<div className="flex flex-col items-center pr-3 gap-2.5">
|
||||
<ClineLogoVariable className="size-16 mb-4" environment={environment} />
|
||||
|
||||
<p>
|
||||
@@ -23,7 +23,7 @@ export const AccountWelcomeView = () => {
|
||||
Sign up with Cline
|
||||
{isLoginLoading && (
|
||||
<span className="ml-1 animate-spin">
|
||||
<span className="codicon codicon-refresh"></span>
|
||||
<span className="codicon codicon-refresh" />
|
||||
</span>
|
||||
)}
|
||||
</VSCodeButton>
|
||||
|
||||
@@ -33,7 +33,7 @@ const ViewHeader = ({ title, onDone, showEnvironmentSuffix, environment }: ViewH
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button size="header" onClick={onDone}>
|
||||
<Button onClick={onDone} size="header">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -9,8 +9,8 @@ import { Button } from "@/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { TaskServiceClient } from "@/services/grpc-client"
|
||||
import { getEnvironmentColor } from "@/utils/environmentColors"
|
||||
import { formatSize } from "@/utils/format"
|
||||
import ViewHeader from "../common/ViewHeader"
|
||||
import HistoryViewItem from "./HistoryViewItem"
|
||||
|
||||
type HistoryViewProps = {
|
||||
@@ -157,9 +157,8 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
setSelectedItems((prev) => {
|
||||
if (checked) {
|
||||
return [...prev, itemId]
|
||||
} else {
|
||||
return prev.filter((id) => id !== itemId)
|
||||
}
|
||||
return prev.filter((id) => id !== itemId)
|
||||
})
|
||||
}, [])
|
||||
|
||||
@@ -292,7 +291,16 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
return (
|
||||
<div className="fixed overflow-hidden inset-0 flex flex-col w-full">
|
||||
{/* HEADER */}
|
||||
<ViewHeader environment={environment} onDone={onDone} title="History" />
|
||||
<div className="flex justify-between items-center py-2.5 px-5">
|
||||
<h3
|
||||
className="m-0"
|
||||
style={{
|
||||
color: getEnvironmentColor(environment),
|
||||
}}>
|
||||
History
|
||||
</h3>
|
||||
<Button onClick={() => onDone()}>Done</Button>
|
||||
</div>
|
||||
|
||||
{/* FILTERS */}
|
||||
<div className="flex flex-col gap-3 px-3">
|
||||
@@ -460,7 +468,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
}
|
||||
|
||||
// https://gist.github.com/evenfrost/1ba123656ded32fb7a0cd4651efd4db0
|
||||
export const highlight = (fuseSearchResult: FuseResult<any>[], highlightClassName: string = "history-item-highlight") => {
|
||||
export const highlight = (fuseSearchResult: FuseResult<any>[], highlightClassName = "history-item-highlight") => {
|
||||
const set = (obj: Record<string, any>, path: string, value: any) => {
|
||||
const pathValue = path.split(".")
|
||||
let i: number
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
import type { PromptsCatalog } from "@shared/prompts"
|
||||
import { ApplyPromptRequest, RemovePromptRequest } from "@shared/proto/cline/prompts"
|
||||
import { VSCodeButton, VSCodeDropdown, VSCodeOption, VSCodeProgressRing, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import Fuse from "fuse.js"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { PromptsServiceClient } from "@/services/grpc-client"
|
||||
import PromptsSubmitCard from "./PromptsSubmitCard"
|
||||
|
||||
type PromptsLibraryTabProps = {
|
||||
catalog: PromptsCatalog
|
||||
}
|
||||
|
||||
const PromptsLibraryTab = ({ catalog }: PromptsLibraryTabProps) => {
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [typeFilter, setTypeFilter] = useState<"all" | "rule" | "workflow" | "hook" | "skill">("all")
|
||||
const [categoryFilter, setCategoryFilter] = useState("all")
|
||||
const [applyingPromptId, setApplyingPromptId] = useState<string | null>(null)
|
||||
const [removingPromptId, setRemovingPromptId] = useState<string | null>(null)
|
||||
const [appliedPrompts, setAppliedPrompts] = useState<Set<string>>(new Set())
|
||||
const [expandedPromptIds, setExpandedPromptIds] = useState<Set<string>>(new Set())
|
||||
const [toastMessage, setToastMessage] = useState<{
|
||||
message: string
|
||||
type: "success" | "error"
|
||||
} | null>(null)
|
||||
|
||||
// Extract unique categories from catalog
|
||||
const categories = useMemo(() => {
|
||||
const cats = new Set<string>()
|
||||
catalog.items.forEach((item) => {
|
||||
if (item.category) {
|
||||
cats.add(item.category)
|
||||
}
|
||||
})
|
||||
return Array.from(cats).sort()
|
||||
}, [catalog.items])
|
||||
|
||||
// Set up Fuse.js for fuzzy search
|
||||
const fuse = useMemo(() => {
|
||||
return new Fuse(catalog.items, {
|
||||
keys: ["name", "description", "author", "category", "tags"],
|
||||
threshold: 0.4,
|
||||
shouldSort: true,
|
||||
isCaseSensitive: false,
|
||||
})
|
||||
}, [catalog.items])
|
||||
|
||||
// Filter and search prompts
|
||||
const filteredPrompts = useMemo(() => {
|
||||
let results = catalog.items
|
||||
|
||||
// Apply type filter
|
||||
if (typeFilter !== "all") {
|
||||
results = results.filter((item) => item.type === typeFilter)
|
||||
}
|
||||
|
||||
// Apply category filter
|
||||
if (categoryFilter !== "all") {
|
||||
results = results.filter((item) => item.category === categoryFilter)
|
||||
}
|
||||
|
||||
// Apply search
|
||||
if (searchTerm) {
|
||||
const searchResults = fuse.search(searchTerm)
|
||||
const searchIds = new Set(searchResults.map((r) => r.item.promptId))
|
||||
results = results.filter((item) => searchIds.has(item.promptId))
|
||||
}
|
||||
|
||||
return results
|
||||
}, [catalog.items, typeFilter, categoryFilter, searchTerm, fuse])
|
||||
|
||||
// Show toast notification
|
||||
const showToast = (message: string, type: "success" | "error") => {
|
||||
setToastMessage({ message, type })
|
||||
}
|
||||
|
||||
const promptTypeToProto = (type: string): number => {
|
||||
switch (type) {
|
||||
case "rule":
|
||||
return 1
|
||||
case "workflow":
|
||||
return 2
|
||||
case "hook":
|
||||
return 3
|
||||
case "skill":
|
||||
return 4
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
const promptTypeToDirectory = (type: string): string => {
|
||||
switch (type) {
|
||||
case "rule":
|
||||
return ".clinerules"
|
||||
case "workflow":
|
||||
return ".clinerules/workflows"
|
||||
case "hook":
|
||||
return ".clinerules/hooks"
|
||||
case "skill":
|
||||
return ".clinerules/skills"
|
||||
default:
|
||||
return ".clinerules"
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyPrompt = async (compositeId: string, promptId: string, type: string, content: string, name: string) => {
|
||||
setApplyingPromptId(compositeId)
|
||||
try {
|
||||
const request = ApplyPromptRequest.create({
|
||||
promptId,
|
||||
type: promptTypeToProto(type),
|
||||
content,
|
||||
name,
|
||||
})
|
||||
|
||||
const result = await PromptsServiceClient.applyPrompt(request)
|
||||
|
||||
if (result.value) {
|
||||
setAppliedPrompts((prev) => new Set(prev).add(`${type}:${promptId}`))
|
||||
showToast(`✓ "${name}" added to ${promptTypeToDirectory(type)}/`, "success")
|
||||
} else {
|
||||
showToast(`✗ Failed to apply "${name}"`, "error")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error applying prompt:", error)
|
||||
showToast(`✗ Error applying prompt: ${error}`, "error")
|
||||
} finally {
|
||||
setApplyingPromptId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemovePrompt = async (compositeId: string, promptId: string, type: string, name: string) => {
|
||||
setRemovingPromptId(compositeId)
|
||||
try {
|
||||
const request = RemovePromptRequest.create({
|
||||
promptId,
|
||||
type: promptTypeToProto(type),
|
||||
name,
|
||||
})
|
||||
|
||||
const result = await PromptsServiceClient.removePrompt(request)
|
||||
|
||||
if (result.value) {
|
||||
setAppliedPrompts((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(`${type}:${promptId}`)
|
||||
return newSet
|
||||
})
|
||||
showToast(`✓ "${name}" removed`, "success")
|
||||
} else {
|
||||
showToast(`✗ Failed to remove "${name}"`, "error")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error removing prompt:", error)
|
||||
showToast(`✗ Error removing prompt: ${error}`, "error")
|
||||
} finally {
|
||||
setRemovingPromptId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = (
|
||||
compositeId: string,
|
||||
promptId: string,
|
||||
type: string,
|
||||
content: string,
|
||||
name: string,
|
||||
isCurrentlyApplied: boolean,
|
||||
) => {
|
||||
if (isCurrentlyApplied) {
|
||||
handleRemovePrompt(compositeId, promptId, type, name)
|
||||
} else {
|
||||
handleApplyPrompt(compositeId, promptId, type, content, name)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(date.getDate()).padStart(2, "0")
|
||||
const year = date.getFullYear()
|
||||
return `${month}.${day}.${year}`
|
||||
} catch {
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-hide toast after 5 seconds
|
||||
useEffect(() => {
|
||||
if (toastMessage) {
|
||||
const timer = setTimeout(() => setToastMessage(null), 5000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [toastMessage])
|
||||
|
||||
// Fetch applied prompts on mount
|
||||
useEffect(() => {
|
||||
const fetchAppliedPrompts = async () => {
|
||||
try {
|
||||
const result = await PromptsServiceClient.getAppliedPrompts({})
|
||||
if (result.values) {
|
||||
setAppliedPrompts(new Set(result.values))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching applied prompts:", error)
|
||||
}
|
||||
}
|
||||
fetchAppliedPrompts()
|
||||
}, [])
|
||||
|
||||
if (!catalog.items || catalog.items.length === 0) {
|
||||
// If lastUpdated is set, the fetch completed but returned no items (error or empty repo)
|
||||
if (catalog.lastUpdated) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: "40px 20px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
textAlign: "center",
|
||||
gap: "8px",
|
||||
}}>
|
||||
<span className="codicon codicon-warning" style={{ fontSize: "24px" }} />
|
||||
<p style={{ margin: 0 }}>Unable to load prompts catalog.</p>
|
||||
<p style={{ margin: 0, fontSize: "12px" }}>
|
||||
This may be due to GitHub API rate limiting. Please try again later.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: "40px 20px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
<VSCodeProgressRing />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: "20px", position: "relative" }}>
|
||||
{/* Toast Notification */}
|
||||
{toastMessage && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: "20px",
|
||||
right: "20px",
|
||||
padding: "12px 20px",
|
||||
borderRadius: "4px",
|
||||
backgroundColor:
|
||||
toastMessage.type === "success"
|
||||
? "var(--vscode-terminal-ansiGreen)"
|
||||
: "var(--vscode-errorForeground)",
|
||||
color: "var(--vscode-editor-background)",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.3)",
|
||||
zIndex: 9999,
|
||||
maxWidth: "400px",
|
||||
fontSize: "13px",
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{toastMessage.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: "0" }}>
|
||||
<h4 style={{ margin: "0 0 8px 0" }}>Community Prompts Library</h4>
|
||||
<p style={{ fontSize: "12px", color: "var(--vscode-descriptionForeground)", margin: "0 0 16px 0" }}>
|
||||
{catalog.items.length} prompts available from the community
|
||||
</p>
|
||||
|
||||
{/* Search Input */}
|
||||
<VSCodeTextField
|
||||
onInput={(e: any) => setSearchTerm(e.target?.value || "")}
|
||||
placeholder="Search prompts..."
|
||||
style={{ width: "100%", marginBottom: "12px" }}
|
||||
value={searchTerm}>
|
||||
<span
|
||||
className="codicon codicon-search"
|
||||
slot="start"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
fontSize: "14px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}
|
||||
/>
|
||||
{searchTerm && (
|
||||
<div
|
||||
className="input-icon-button codicon codicon-close"
|
||||
onClick={() => setSearchTerm("")}
|
||||
slot="end"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</VSCodeTextField>
|
||||
|
||||
{/* Filters */}
|
||||
<div style={{ display: "flex", gap: "12px", marginBottom: "16px" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label htmlFor="type-filter" style={{ fontSize: "12px", marginBottom: "4px", display: "block" }}>
|
||||
Type
|
||||
</label>
|
||||
<VSCodeDropdown
|
||||
id="type-filter"
|
||||
onChange={(e: any) => setTypeFilter(e.target.value)}
|
||||
style={{ width: "100%" }}
|
||||
value={typeFilter}>
|
||||
<VSCodeOption value="all">All Types</VSCodeOption>
|
||||
<VSCodeOption value="rule">Rules</VSCodeOption>
|
||||
<VSCodeOption value="workflow">Workflows</VSCodeOption>
|
||||
<VSCodeOption value="hook">Hooks</VSCodeOption>
|
||||
<VSCodeOption value="skill">Skills</VSCodeOption>
|
||||
</VSCodeDropdown>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<label htmlFor="category-filter" style={{ fontSize: "12px", marginBottom: "4px", display: "block" }}>
|
||||
Category
|
||||
</label>
|
||||
<VSCodeDropdown
|
||||
id="category-filter"
|
||||
onChange={(e: any) => setCategoryFilter(e.target.value)}
|
||||
style={{ width: "100%" }}
|
||||
value={categoryFilter}>
|
||||
<VSCodeOption value="all">All Categories</VSCodeOption>
|
||||
{categories.map((cat) => (
|
||||
<VSCodeOption key={cat} value={cat}>
|
||||
{cat}
|
||||
</VSCodeOption>
|
||||
))}
|
||||
</VSCodeDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashed Separator */}
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px dashed var(--vscode-panel-border)",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Prompt List */}
|
||||
{filteredPrompts.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "40px 20px",
|
||||
textAlign: "center",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
<p>No prompts found matching your filters.</p>
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
onClick={() => {
|
||||
setSearchTerm("")
|
||||
setTypeFilter("all")
|
||||
setCategoryFilter("all")
|
||||
}}
|
||||
style={{ marginTop: "12px" }}>
|
||||
Clear Filters
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||
{filteredPrompts.map((prompt) => {
|
||||
const compositeId = `${prompt.type}:${prompt.promptId}`
|
||||
const isApplied = appliedPrompts.has(compositeId)
|
||||
const isProcessing = applyingPromptId === compositeId || removingPromptId === compositeId
|
||||
const isExpanded = expandedPromptIds.has(compositeId)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={compositeId}
|
||||
style={{
|
||||
padding: "16px",
|
||||
border: "1px solid var(--vscode-panel-border)",
|
||||
borderRadius: "6px",
|
||||
transition: "background-color 0.2s, border-color 0.2s",
|
||||
backgroundColor: isApplied ? "var(--vscode-list-hoverBackground)" : "transparent",
|
||||
}}>
|
||||
{/* Header: Name + Toggle */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
marginBottom: "4px",
|
||||
}}>
|
||||
<h5 style={{ margin: 0, fontSize: "14px", fontWeight: 600 }}>{prompt.name}</h5>
|
||||
<Switch
|
||||
checked={isApplied}
|
||||
disabled={isProcessing}
|
||||
onClick={() =>
|
||||
handleToggle(
|
||||
compositeId,
|
||||
prompt.promptId,
|
||||
prompt.type,
|
||||
prompt.content,
|
||||
prompt.name,
|
||||
isApplied,
|
||||
)
|
||||
}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category with icon */}
|
||||
{prompt.category && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
marginBottom: "10px",
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
<span className="codicon codicon-git-pull-request" style={{ fontSize: "12px" }} />
|
||||
{prompt.category}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<p
|
||||
style={{
|
||||
margin: "0 0 12px 0",
|
||||
fontSize: "13px",
|
||||
color: "var(--vscode-foreground)",
|
||||
lineHeight: "1.4",
|
||||
}}>
|
||||
{prompt.description}
|
||||
</p>
|
||||
|
||||
{/* Footer: Type badge + Expand chevron */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
padding: "2px 8px",
|
||||
borderRadius: "3px",
|
||||
border: "1px solid var(--vscode-descriptionForeground)",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
{prompt.type.charAt(0).toUpperCase() + prompt.type.slice(1)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
setExpandedPromptIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(compositeId)) {
|
||||
next.delete(compositeId)
|
||||
} else {
|
||||
next.add(compositeId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: "2px 4px",
|
||||
color: "var(--vscode-foreground)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
title={isExpanded ? "Collapse details" : "Expand details"}>
|
||||
<span
|
||||
className={`codicon ${isExpanded ? "codicon-chevron-up" : "codicon-chevron-down"}`}
|
||||
style={{ fontSize: "14px" }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expandable Metadata Section */}
|
||||
{isExpanded && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "12px",
|
||||
border: "1px solid var(--vscode-descriptionForeground)",
|
||||
borderRadius: "4px",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "12px",
|
||||
}}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
Published by
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
textAlign: "right",
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
{prompt.author || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
Last updated
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
textAlign: "right",
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
{prompt.updatedAt ? formatDate(prompt.updatedAt) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
Version
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
textAlign: "right",
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
{prompt.version || "—"}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Prompts Card */}
|
||||
<PromptsSubmitCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PromptsLibraryTab
|
||||
@@ -0,0 +1,48 @@
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { getEnvironmentColor } from "@/utils/environmentColors"
|
||||
import PromptsLibraryTab from "./PromptsLibraryTab"
|
||||
|
||||
type PromptsLibraryViewProps = {
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
const PromptsLibraryView = ({ onDone }: PromptsLibraryViewProps) => {
|
||||
const { environment, promptsCatalog } = useExtensionState()
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "10px 17px 5px 20px",
|
||||
}}>
|
||||
<h3
|
||||
style={{
|
||||
color: getEnvironmentColor(environment),
|
||||
margin: 0,
|
||||
}}>
|
||||
Prompts Library
|
||||
</h3>
|
||||
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto" }}>
|
||||
<PromptsLibraryTab catalog={promptsCatalog} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PromptsLibraryView
|
||||
@@ -0,0 +1,45 @@
|
||||
const PromptsSubmitCard = () => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
padding: "15px",
|
||||
margin: "20px 0",
|
||||
backgroundColor: "var(--vscode-textBlockQuote-background)",
|
||||
borderRadius: "6px",
|
||||
}}>
|
||||
{/* Icon */}
|
||||
<i className="codicon codicon-add" style={{ fontSize: "18px" }} />
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
textAlign: "center",
|
||||
maxWidth: "480px",
|
||||
}}>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: "14px",
|
||||
fontWeight: 600,
|
||||
color: "var(--vscode-foreground)",
|
||||
}}>
|
||||
Submit Prompts
|
||||
</h3>
|
||||
<p style={{ fontSize: "13px", margin: 0, color: "var(--vscode-descriptionForeground)" }}>
|
||||
Help others discover great rules, workflows, hooks, and skills by submitting an issue to{" "}
|
||||
<a href="https://github.com/cline/prompts">github.com/cline/prompts</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PromptsSubmitCard
|
||||
@@ -24,14 +24,14 @@ const buttonVariants = cva(
|
||||
"bg-success/10 text-success border-[#176f2c] text-white hover:bg-[#197f31] hover:border-[#197f31] active:bg-[#156528] active:border-[#156528] hover:text-white",
|
||||
danger: "bg-[#c42b2b] border-[#c42b2b]! text-white! hover:bg-[#a82424]! hover:border-[#a82424]! active:bg-[#8f1f1f]! active:border-[#8f1f1f]!",
|
||||
},
|
||||
size: {
|
||||
default: "py-1.5 px-4 [&_svg]:size-3",
|
||||
sm: "py-1 px-3 text-sm [&_svg]:size-2",
|
||||
xs: "p-1 text-xs [&_svg]:size-2",
|
||||
lg: "py-4 px-8 [&_svg]:size-4 font-medium",
|
||||
icon: "px-0.5 m-0 [&_svg]:size-2",
|
||||
header: "py-1 px-4 [&_svg]:size-2.5",
|
||||
},
|
||||
size: {
|
||||
default: "py-1.5 px-4 [&_svg]:size-3",
|
||||
sm: "py-1 px-3 text-sm [&_svg]:size-2",
|
||||
xs: "p-1 text-xs [&_svg]:size-2",
|
||||
lg: "py-4 px-8 [&_svg]:size-4 font-medium",
|
||||
icon: "px-0.5 m-0 [&_svg]:size-2",
|
||||
header: "py-1 px-4 [&_svg]:size-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
|
||||
@@ -26,7 +26,14 @@ import {
|
||||
} from "../../../src/shared/api"
|
||||
import { Environment } from "../../../src/shared/config-types"
|
||||
import type { McpMarketplaceCatalog, McpServer, McpViewTab } from "../../../src/shared/mcp"
|
||||
import { McpServiceClient, ModelsServiceClient, StateServiceClient, UiServiceClient } from "../services/grpc-client"
|
||||
import type { PromptsCatalog } from "../../../src/shared/prompts"
|
||||
import {
|
||||
McpServiceClient,
|
||||
ModelsServiceClient,
|
||||
PromptsServiceClient,
|
||||
StateServiceClient,
|
||||
UiServiceClient,
|
||||
} from "../services/grpc-client"
|
||||
|
||||
export interface ExtensionStateContextType extends ExtensionState {
|
||||
didHydrateState: boolean
|
||||
@@ -44,6 +51,7 @@ export interface ExtensionStateContextType extends ExtensionState {
|
||||
huggingFaceModels: Record<string, ModelInfo>
|
||||
mcpServers: McpServer[]
|
||||
mcpMarketplaceCatalog: McpMarketplaceCatalog
|
||||
promptsCatalog: PromptsCatalog
|
||||
totalTasksSize: number | null
|
||||
lastDismissedCliBannerVersion: number
|
||||
dismissedBanners?: Array<{ bannerId: string; dismissedAt: number }>
|
||||
@@ -53,6 +61,7 @@ export interface ExtensionStateContextType extends ExtensionState {
|
||||
// View state
|
||||
showMcp: boolean
|
||||
mcpTab?: McpViewTab
|
||||
showPrompts: boolean
|
||||
showSettings: boolean
|
||||
settingsTargetSection?: string
|
||||
settingsInitialModelTab?: "recommended" | "free"
|
||||
@@ -82,6 +91,7 @@ export interface ExtensionStateContextType extends ExtensionState {
|
||||
setRemoteRulesToggles: (toggles: Record<string, boolean>) => void
|
||||
setRemoteWorkflowToggles: (toggles: Record<string, boolean>) => void
|
||||
setMcpMarketplaceCatalog: (value: McpMarketplaceCatalog) => void
|
||||
setPromptsCatalog: (value: PromptsCatalog) => void
|
||||
setTotalTasksSize: (value: number | null) => void
|
||||
setExpandTaskHeader: (value: boolean) => void
|
||||
setShowWelcome: (value: boolean) => void
|
||||
@@ -101,6 +111,7 @@ export interface ExtensionStateContextType extends ExtensionState {
|
||||
|
||||
// Navigation functions
|
||||
navigateToMcp: (tab?: McpViewTab) => void
|
||||
navigateToPrompts: () => void
|
||||
navigateToSettings: (targetSection?: string) => void
|
||||
navigateToSettingsModelPicker: (opts: { targetSection?: string; initialModelTab?: "recommended" | "free" }) => void
|
||||
navigateToHistory: () => void
|
||||
@@ -113,6 +124,7 @@ export interface ExtensionStateContextType extends ExtensionState {
|
||||
hideHistory: () => void
|
||||
hideAccount: () => void
|
||||
hideWorktrees: () => void
|
||||
hidePrompts: () => void
|
||||
hideAnnouncement: () => void
|
||||
closeMcpView: () => void
|
||||
|
||||
@@ -160,6 +172,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
setShowHistory(false)
|
||||
setShowAccount(false)
|
||||
setShowWorktrees(false)
|
||||
setShowPrompts(false)
|
||||
if (tab) {
|
||||
setMcpTab(tab)
|
||||
}
|
||||
@@ -174,6 +187,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
closeMcpView()
|
||||
setShowAccount(false)
|
||||
setShowWorktrees(false)
|
||||
setShowPrompts(false)
|
||||
setSettingsTargetSection(targetSection)
|
||||
setSettingsInitialModelTab(undefined)
|
||||
setShowSettings(true)
|
||||
@@ -187,6 +201,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
closeMcpView()
|
||||
setShowAccount(false)
|
||||
setShowWorktrees(false)
|
||||
setShowPrompts(false)
|
||||
setSettingsTargetSection(opts.targetSection)
|
||||
setSettingsInitialModelTab(opts.initialModelTab)
|
||||
setShowSettings(true)
|
||||
@@ -199,6 +214,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
closeMcpView()
|
||||
setShowAccount(false)
|
||||
setShowWorktrees(false)
|
||||
setShowPrompts(false)
|
||||
setShowHistory(true)
|
||||
}, [setShowSettings, closeMcpView, setShowAccount, setShowWorktrees, setShowHistory])
|
||||
|
||||
@@ -207,6 +223,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
closeMcpView()
|
||||
setShowHistory(false)
|
||||
setShowWorktrees(false)
|
||||
setShowPrompts(false)
|
||||
setShowAccount(true)
|
||||
}, [setShowSettings, closeMcpView, setShowHistory, setShowWorktrees, setShowAccount])
|
||||
|
||||
@@ -215,6 +232,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
closeMcpView()
|
||||
setShowHistory(false)
|
||||
setShowAccount(false)
|
||||
setShowPrompts(false)
|
||||
setShowWorktrees(true)
|
||||
}, [setShowSettings, closeMcpView, setShowHistory, setShowAccount, setShowWorktrees])
|
||||
|
||||
@@ -224,8 +242,22 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
setShowHistory(false)
|
||||
setShowAccount(false)
|
||||
setShowWorktrees(false)
|
||||
setShowPrompts(false)
|
||||
}, [setShowSettings, closeMcpView, setShowHistory, setShowAccount, setShowWorktrees])
|
||||
|
||||
const navigateToPrompts = useCallback(() => {
|
||||
setShowSettings(false)
|
||||
closeMcpView()
|
||||
setShowHistory(false)
|
||||
setShowAccount(false)
|
||||
setShowWorktrees(false)
|
||||
setShowPrompts(true)
|
||||
}, [closeMcpView])
|
||||
|
||||
const hidePrompts = useCallback(() => {
|
||||
setShowPrompts(false)
|
||||
}, [])
|
||||
|
||||
const [state, setState] = useState<ExtensionState>({
|
||||
version: "",
|
||||
clineMessages: [],
|
||||
@@ -321,6 +353,8 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
const [huggingFaceModels, setHuggingFaceModels] = useState<Record<string, ModelInfo>>({})
|
||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
|
||||
const [mcpMarketplaceCatalog, setMcpMarketplaceCatalog] = useState<McpMarketplaceCatalog>({ items: [] })
|
||||
const [promptsCatalog, setPromptsCatalog] = useState<PromptsCatalog>({ items: [], lastUpdated: "" })
|
||||
const [showPrompts, setShowPrompts] = useState(false)
|
||||
|
||||
// References to store subscription cancellation functions
|
||||
const stateSubscriptionRef = useRef<(() => void) | null>(null)
|
||||
@@ -333,6 +367,8 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
const worktreesButtonClickedSubscriptionRef = useRef<(() => void) | null>(null)
|
||||
const partialMessageUnsubscribeRef = useRef<(() => void) | null>(null)
|
||||
const mcpMarketplaceUnsubscribeRef = useRef<(() => void) | null>(null)
|
||||
const promptsCatalogUnsubscribeRef = useRef<(() => void) | null>(null)
|
||||
const promptsButtonClickedSubscriptionRef = useRef<(() => void) | null>(null)
|
||||
const openRouterModelsUnsubscribeRef = useRef<(() => void) | null>(null)
|
||||
const liteLlmModelsUnsubscribeRef = useRef<(() => void) | null>(null)
|
||||
const workspaceUpdatesUnsubscribeRef = useRef<(() => void) | null>(null)
|
||||
@@ -392,10 +428,8 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error parsing state JSON:", error)
|
||||
console.log("[DEBUG] ERR getting state", error)
|
||||
}
|
||||
}
|
||||
console.log('[DEBUG] ended "got subscribed state"')
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error in state subscription:", error)
|
||||
@@ -410,7 +444,6 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
{},
|
||||
{
|
||||
onResponse: () => {
|
||||
console.log("[DEBUG] Received mcpButtonClicked event from gRPC stream")
|
||||
navigateToMcp()
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -427,8 +460,6 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
{},
|
||||
{
|
||||
onResponse: () => {
|
||||
// When history button is clicked, navigate to history view
|
||||
console.log("[DEBUG] Received history button clicked event from gRPC stream")
|
||||
navigateToHistory()
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -446,7 +477,6 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
{
|
||||
onResponse: () => {
|
||||
// When chat button is clicked, navigate to chat
|
||||
console.log("[DEBUG] Received chat button clicked event from gRPC stream")
|
||||
navigateToChat()
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -459,7 +489,6 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
// Subscribe to MCP servers updates
|
||||
mcpServersSubscriptionRef.current = McpServiceClient.subscribeToMcpServers(EmptyRequest.create(), {
|
||||
onResponse: (response) => {
|
||||
console.log("[DEBUG] Received MCP servers update from gRPC stream")
|
||||
if (response.mcpServers) {
|
||||
setMcpServers(convertProtoMcpServersToMcpServers(response.mcpServers))
|
||||
}
|
||||
@@ -503,6 +532,20 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
},
|
||||
)
|
||||
|
||||
// Set up prompts button clicked subscription
|
||||
promptsButtonClickedSubscriptionRef.current = UiServiceClient.subscribeToPromptsButtonClicked(EmptyRequest.create({}), {
|
||||
onResponse: () => {
|
||||
// When prompts button is clicked, navigate to prompts
|
||||
navigateToPrompts()
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error in prompts button clicked subscription:", error)
|
||||
},
|
||||
onComplete: () => {
|
||||
console.log("Prompts button clicked subscription completed")
|
||||
},
|
||||
})
|
||||
|
||||
// Subscribe to partial message events
|
||||
partialMessageUnsubscribeRef.current = UiServiceClient.subscribeToPartialMessage(EmptyRequest.create({}), {
|
||||
onResponse: (protoMessage) => {
|
||||
@@ -531,15 +574,12 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
onError: (error) => {
|
||||
console.error("Error in partialMessage subscription:", error)
|
||||
},
|
||||
onComplete: () => {
|
||||
console.log("[DEBUG] partialMessage subscription completed")
|
||||
},
|
||||
onComplete: () => {},
|
||||
})
|
||||
|
||||
// Subscribe to MCP marketplace catalog updates
|
||||
mcpMarketplaceUnsubscribeRef.current = McpServiceClient.subscribeToMcpMarketplaceCatalog(EmptyRequest.create({}), {
|
||||
onResponse: (catalog) => {
|
||||
console.log("[DEBUG] Received MCP marketplace catalog update from gRPC stream")
|
||||
setMcpMarketplaceCatalog(catalog)
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -550,6 +590,41 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
},
|
||||
})
|
||||
|
||||
// Subscribe to prompts catalog updates
|
||||
promptsCatalogUnsubscribeRef.current = PromptsServiceClient.subscribeToPromptsCatalog(EmptyRequest.create({}), {
|
||||
onResponse: (protoCatalog) => {
|
||||
// Convert proto types to shared types
|
||||
const protoTypeToString = (t: number): "rule" | "workflow" | "hook" | "skill" => {
|
||||
switch (t) {
|
||||
case 1:
|
||||
return "rule"
|
||||
case 2:
|
||||
return "workflow"
|
||||
case 3:
|
||||
return "hook"
|
||||
case 4:
|
||||
return "skill"
|
||||
default:
|
||||
return "rule"
|
||||
}
|
||||
}
|
||||
const catalog = {
|
||||
items: protoCatalog.items.map((item) => ({
|
||||
...item,
|
||||
type: protoTypeToString(item.type),
|
||||
})),
|
||||
lastUpdated: protoCatalog.lastUpdated,
|
||||
}
|
||||
setPromptsCatalog(catalog)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error in prompts catalog subscription:", error)
|
||||
},
|
||||
onComplete: () => {
|
||||
console.log("Prompts catalog subscription completed")
|
||||
},
|
||||
})
|
||||
|
||||
// Subscribe to OpenRouter models updates
|
||||
openRouterModelsUnsubscribeRef.current = ModelsServiceClient.subscribeToOpenRouterModels(EmptyRequest.create({}), {
|
||||
onResponse: (response: OpenRouterCompatibleModelInfo) => {
|
||||
@@ -583,9 +658,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
|
||||
// Initialize webview using gRPC
|
||||
UiServiceClient.initializeWebview(EmptyRequest.create({}))
|
||||
.then(() => {
|
||||
console.log("[DEBUG] Webview initialization completed via gRPC")
|
||||
})
|
||||
.then(() => {})
|
||||
.catch((error) => {
|
||||
console.error("Failed to initialize webview via gRPC:", error)
|
||||
})
|
||||
@@ -594,7 +667,6 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
accountButtonClickedSubscriptionRef.current = UiServiceClient.subscribeToAccountButtonClicked(EmptyRequest.create(), {
|
||||
onResponse: () => {
|
||||
// When account button is clicked, navigate to account view
|
||||
console.log("[DEBUG] Received account button clicked event from gRPC stream")
|
||||
navigateToAccount()
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -666,6 +738,14 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
mcpMarketplaceUnsubscribeRef.current()
|
||||
mcpMarketplaceUnsubscribeRef.current = null
|
||||
}
|
||||
if (promptsCatalogUnsubscribeRef.current) {
|
||||
promptsCatalogUnsubscribeRef.current()
|
||||
promptsCatalogUnsubscribeRef.current = null
|
||||
}
|
||||
if (promptsButtonClickedSubscriptionRef.current) {
|
||||
promptsButtonClickedSubscriptionRef.current()
|
||||
promptsButtonClickedSubscriptionRef.current = null
|
||||
}
|
||||
if (openRouterModelsUnsubscribeRef.current) {
|
||||
openRouterModelsUnsubscribeRef.current()
|
||||
openRouterModelsUnsubscribeRef.current = null
|
||||
@@ -800,10 +880,12 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
huggingFaceModels,
|
||||
mcpServers,
|
||||
mcpMarketplaceCatalog,
|
||||
promptsCatalog,
|
||||
totalTasksSize,
|
||||
availableTerminalProfiles,
|
||||
showMcp,
|
||||
mcpTab,
|
||||
showPrompts,
|
||||
showSettings,
|
||||
settingsTargetSection,
|
||||
settingsInitialModelTab,
|
||||
@@ -825,6 +907,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
|
||||
// Navigation functions
|
||||
navigateToMcp,
|
||||
navigateToPrompts,
|
||||
navigateToSettings,
|
||||
navigateToSettingsModelPicker,
|
||||
navigateToHistory,
|
||||
@@ -837,6 +920,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
hideHistory,
|
||||
hideAccount,
|
||||
hideWorktrees,
|
||||
hidePrompts,
|
||||
hideAnnouncement,
|
||||
setShowAnnouncement,
|
||||
setShowWelcome,
|
||||
@@ -852,6 +936,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
setBasetenModels,
|
||||
setHuggingFaceModels,
|
||||
setMcpMarketplaceCatalog,
|
||||
setPromptsCatalog,
|
||||
setShowMcp,
|
||||
closeMcpView,
|
||||
setGlobalClineRulesToggles: (toggles) =>
|
||||
|
||||
Reference in New Issue
Block a user