Compare commits

...

73 Commits

Author SHA1 Message Date
Tony Loehr 2f9b0aaf30 Merge branch 'main' into prompts-library-mvp 2026-04-06 21:35:27 +00:00
Tony Loehr 0957823420 Merge branch 'main' into prompts-library-mvp 2026-04-03 00:42:04 +00:00
Tony Loehr 3d5885c49c Merge branch 'prompts-library-mvp' of https://github.com/cline/cline into prompts-library-mvp 2026-04-01 15:37:21 -07:00
Tony Loehr afa6368d70 added authenticated gh pull option 2026-04-01 15:35:37 -07:00
Tony Loehr 29520a3a6a Merge branch 'main' of https://github.com/cline/cline into prompts-library-mvp 2026-04-01 15:11:41 -07:00
Tony Loehr 841b95902f Merge branch 'main' of https://github.com/cline/cline into prompts-library-mvp 2026-03-29 22:23:35 -07:00
Tony Loehr 093e948c54 Merge branch 'main' into prompts-library-mvp 2026-03-27 19:28:21 +00:00
Tony Loehr d0c4e6b535 Merge branch 'main' into prompts-library-mvp 2026-03-27 17:06:06 +00:00
Tony Loehr 1e084e86a7 fix: remove per-file GitHub API calls to prevent rate limiting
Remove fetchLastCommitDate() which made N individual Commits API calls
(one per prompt file), quickly exhausting the 60 req/hour unauthenticated
GitHub API limit. The service now makes only 1 rate-limited call (Git Tree
API) plus N CDN fetches (not rate-limited).
2026-03-27 10:05:17 -07:00
Tony Loehr 95218bb793 Merge branch 'main' into prompts-library-mvp 2026-03-26 01:56:10 +00:00
Tony Loehr 3a80348458 Merge branch 'main' into prompts-library-mvp 2026-03-18 14:50:08 -07:00
Tony Loehr 1fad8cecd2 Merge branch 'main' into prompts-library-mvp 2026-03-18 12:41:10 -07:00
Tony Loehr d4e3ca4002 Merge branch 'main' into prompts-library-mvp 2026-03-18 12:19:01 -07:00
Tony Loehr f98d9108e5 Merge branch 'main' into prompts-library-mvp 2026-03-17 16:50:57 -07:00
Tony Loehr 8e3a0ea3ba Merge branch 'main' into prompts-library-mvp 2026-03-17 13:31:11 -07:00
Tony Loehr 396551d46f Merge branch 'main' into prompts-library-mvp 2026-03-16 18:31:19 -07:00
Tony Loehr 1ee291160b Merge branch 'main' into prompts-library-mvp 2026-03-16 14:56:54 -07:00
Tony Loehr 8b7a8c42c5 Potential fix for code scanning alert no. 145: Incomplete URL substring sanitization
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-12 23:49:43 -07:00
Tony Loehr a48a82796f Merge branch 'main' into prompts-library-mvp 2026-03-12 23:43:27 -07:00
Tony Loehr 15460053c2 feat: update prompt library apply/remove logic, service, and UI 2026-03-12 23:34:35 -07:00
Tony Loehr 7df2554a11 Merge branch 'prompts-library-mvp' of https://github.com/cline/cline into prompts-library-mvp 2026-03-10 10:40:36 -07:00
Tony Loehr c3849efe17 feat: add Submit Prompts card, use Git Trees API, fix type conversions
- Add PromptsSubmitCard at bottom of Prompts Library (mirrors MCP Marketplace)
- Rewrite PromptsService to use Git Trees API (1 rate-limited call vs 46+)
- Fix prompt type conversion bugs (hook/skill were mapped as workflow)
- Add error state UI when catalog fails to load (replaces infinite spinner)
- Add 10s timeout to GitHub API calls
2026-03-10 10:38:32 -07:00
Tony Loehr 181f131ff9 Merge branch 'main' into prompts-library-mvp 2026-03-10 06:22:53 -07:00
Tony Loehr 482efd8e4e Merge branch 'main' into prompts-library-mvp 2026-03-08 09:49:29 -07:00
Tony Loehr fd5f59b1a2 fix merge conflict 2026-03-08 09:47:26 -07:00
Tony Loehr 14e07ec0cf removed team prompts section for now 2026-03-06 15:34:55 -08:00
Tony Loehr 03c7a39279 refactor: remove Team Prompts functionality for separate PR
Remove Team Prompts code from the Prompts Library MVP to be submitted
as a separate PR. The Prompts Library functionality with the open source
cline/prompts GitHub connection remains intact.

Removed:
- TeamPrompt/TeamPromptsCatalog types and proto messages
- getTeamPrompts/subscribeToTeamPrompts RPCs and handlers
- TeamPromptsTab component and tab navigation
- Team prompts state/subscriptions from ExtensionStateContext
2026-03-03 20:11:50 -08:00
Tony Loehr b3896df52a Merge branch 'main' into prompts-library-mvp 2026-03-02 22:28:28 -08:00
Tony Loehr af0cfd131a Merge branch 'main' into prompts-library-mvp 2026-03-02 16:23:24 -08:00
Tony Loehr d09b1dfe3b Merge branch 'main' into prompts-library-mvp 2026-03-02 16:10:21 -08:00
Tony Loehr 1876560c04 feat: update prompt library UI with toggle switches and improve metadata reliability
- Replace Apply/Remove buttons with Switch toggle components
- Redesign card layout: title+toggle header, category icon, type badge, expand chevron
- Support multi-card expand simultaneously (Set<string>)
- Metadata table: no internal row separators, lighter borders visible on highlighted cards
- Add .trim() to all frontmatter regex captures
- Parse version, created_at, updated_at from frontmatter
- Backfill dates from GitHub Commits API when frontmatter dates missing
- Add 5 new tests for date parsing, trimming, and commit backfill
2026-03-02 16:08:50 -08:00
Tony Loehr 37587823d8 Merge branch 'main' into prompts-library-mvp 2026-02-23 14:26:26 -08:00
Tony Loehr c34bf8f6a6 fix: restore button header variant and ViewHeader from main
The previous revert commit incorrectly removed the 'header' button size
variant and ViewHeader changes that came from PR #8989 on main, not from
the prompts library PR. This caused a build failure since ViewHeader.tsx
uses size='header' which no longer existed in button.tsx.
2026-02-16 15:55:09 -08:00
Tony Loehr b66dfcb305 fix: add trailing slash to URL substring checks to prevent subdomain matching
Addresses CodeQL 'Incomplete URL substring sanitization' warnings by
changing startsWith('https://raw.githubusercontent.com') to
startsWith('https://raw.githubusercontent.com/') to prevent matching
attacker-controlled subdomains like raw.githubusercontent.com.evil.com
2026-02-16 15:41:44 -08:00
Tony Loehr 8c08c5577d refactor: improve PromptsService testability and URL parsing
- Add protected httpGet() wrapper for test stubbing
- Replace direct axios.get() calls with this.httpGet()
- Improve author URL parsing using URL constructor instead of regex
- Simplify test mock URL matching with startsWith
2026-02-16 15:07:34 -08:00
Tony Loehr f4cec89759 Potential fix for code scanning alert no. 124: Incomplete URL substring sanitization
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-02-16 15:01:55 -08:00
Tony Loehr de9efc22dd Merge remote-tracking branch 'origin/main' into prompts-library-mvp
# Conflicts:
#	package-lock.json
2026-02-16 14:56:29 -08:00
Tony Loehr d107138a3c chore: revert extraneous changes outside prompt library scope
Reverts the following out-of-scope changes that were accidentally included:
- UseMcpToolHandler.ts: Unrelated refactor (extracting mcpHub variable)
- getOpenTabs.ts: Unrelated fix (URI scheme handling)
- adapters.ts: Unrelated refactor (StateStorage type export)
- AccountWelcomeView.tsx: Unrelated UI change (button variant)
- ViewHeader.tsx: Unrelated UI layout change
- HistoryView.tsx: Unrelated UI padding change
- button.tsx: Unrelated style change (ring-offset-0)
- state.proto: Revert field number bump (47→46) for openai_codex_oauth_credentials
- package-lock.json/webview-ui/package-lock.json: Revert unrelated dependency changes
2026-02-16 14:37:37 -08:00
Tony Loehr 79d8638070 Merge branch 'main' into prompts-library-mvp 2026-02-16 12:02:37 -08:00
Tony Loehr 0c6cdb1172 Potential fix for code scanning alert no. 123: Incomplete URL substring sanitization
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-02-16 12:01:26 -08:00
Tony Loehr a09e6d7bb5 Merge branch 'main' into prompts-library-mvp 2026-02-16 09:35:16 -08:00
Tony Loehr 65df0d8029 Merge branch 'prompts-library-mvp' of https://github.com/cline/cline into prompts-library-mvp 2026-02-15 00:12:53 -08:00
Tony Loehr 76be73eb4b added get ability to promptsservice for test purposes 2026-02-15 00:11:51 -08:00
Tony Loehr 05d1206d87 Merge branch 'main' into prompts-library-mvp 2026-02-14 23:31:48 -08:00
Tony Loehr 12f7513be3 Merge branch 'main' into prompts-library-mvp 2026-02-13 23:45:00 -08:00
Tony Loehr 4c70c8a4fe prompt text fix 2026-02-13 15:49:32 -08:00
Tony Loehr 2cd4eb78d1 Merge branch 'main' into prompts-library-mvp 2026-02-13 14:14:00 -08:00
Tony Loehr beee54664c Merge branch 'main' into prompts-library-mvp 2026-02-11 16:21:07 -08:00
Tony Loehr 1c308089ad Merge branch 'main' into prompts-library-mvp 2026-02-11 15:24:34 -08:00
Tony Loehr b6ed545588 Merge branch 'main' into prompts-library-mvp 2026-02-11 13:15:26 -08:00
Tony Loehr 2584f3fe2d Merge branch 'main' into prompts-library-mvp 2026-02-11 11:40:10 -08:00
Tony Loehr 0280bddd71 Merge branch 'main' into prompts-library-mvp 2026-02-10 15:42:51 -08:00
Tony Loehr 412c52c151 Merge origin/main into prompts-library-mvp, resolve workflow conflict 2026-02-10 15:19:11 -08:00
Tony Loehr af3ff08aa6 reduced latency for prompts library 2026-02-09 12:08:13 -08:00
Tony Loehr 1a7f0c43b8 Merge branch 'main' into prompts-library-mvp 2026-02-09 01:04:33 -08:00
Tony Loehr baae18d2cf Merge branch 'prompts-library-mvp' of https://github.com/cline/cline into prompts-library-mvp 2026-02-08 22:43:52 -08:00
Tony Loehr ddd5fdd55a windows test fix 2026-02-08 22:43:33 -08:00
Tony Loehr 99ad4e53c5 Merge branch 'main' into prompts-library-mvp 2026-02-08 19:43:36 -08:00
Tony Loehr fdd2799169 Merge branch 'main' into prompts-library-mvp 2026-02-08 18:12:58 -08:00
Tony Loehr ef89b65d5a fix prompt test 2026-02-08 18:09:22 -08:00
Tony Loehr d2bc5deecb fix: remove console.error to pass lint checks
Remove console.error call that violated lint rule requiring Logger service.
Error handling is already graceful - returns empty array on errors.
2026-02-07 22:34:21 -08:00
Tony Loehr 4914f157b9 test: add comprehensive tests for Prompts Library
- Add PromptsService tests (18 test cases)
  - Frontmatter parsing edge cases
  - GitHub API error handling
  - Caching behavior

- Add controller handler tests (15 test cases)
  - applyPrompt: file operations and error handling
  - removePrompt: file deletion operations
  - getAppliedPrompts: directory scanning

Total: 33 test cases covering all prompts functionality
Status: 14/33 currently passing, 6 need minor fixes for fs stubbing
2026-02-07 20:45:57 -08:00
Tony Loehr fde3a25184 Merge branch 'main' into prompts-library-mvp 2026-02-07 20:41:29 -08:00
Tony Loehr dae27ba137 Merge branch 'main' into prompts-library-mvp 2026-02-07 15:21:55 -08:00
Tony Loehr f5fd4cb058 Merge branch 'main' into prompts-library-mvp 2026-02-07 15:19:11 -08:00
Tony Loehr 6474b3b606 Merge branch 'main' into prompts-library-mvp 2026-02-07 14:00:05 -08:00
Tony Loehr cec3f33ddc fix: address PR review issues for Prompts Library
- Fix memory leak: properly manage prompts button subscription cleanup
- Add changeset for Prompts Library feature
- Implement persistent applied prompts state via workspace scanning
- Remove debug console logs from ExtensionStateContext
- Fix duplicate toast timeout in PromptsLibraryTab

Resolves issues 1, 2, 4, 5, and 6 from code review
2026-02-07 13:58:34 -08:00
Tony Loehr 12f1df55cd workflow fix 2026-02-07 12:50:16 -08:00
Tony Loehr e4a633dd8b Merge branch 'prompts-library-mvp' of https://github.com/cline/cline into prompts-library-mvp 2026-02-07 12:23:39 -08:00
Tony Loehr bac0422173 fix github URL substring sanitization 2026-02-07 12:23:15 -08:00
Tony Loehr da85c5da90 Merge branch 'main' of https://github.com/cline/cline into prompts-library-mvp 2026-02-07 12:22:08 -08:00
Tony Loehr 10e80633d6 Merge branch 'main' into prompts-library-mvp 2026-02-07 11:02:46 -08:00
Tony Loehr cbf9bd8779 feat: implement Prompts Library with community and team prompt support
- Adds a new "Prompts Library" view accessible via the sidebar
- Implements fetching of community prompts from github.com/cline/prompts
- Supports applying prompts directly to the workspace (.clinerules/ or workflows/)
- Adds support for removing applied prompts from the workspace
- Implements fuzzy search and category/type filtering for the prompts library
- Adds a "Team Prompts" tab with enterprise sign-up CTA
- Integrates with gRPC for communication between webview and extension
- Provides visual feedback via toast notifications and applied-state indicators

Technical Changes:
- Defined "PromptsService" gRPC protocol in "proto/cline/prompts.proto"
- Implemented "PromptsService" backend for GitHub fetching and file operations
- Added "PromptsLibraryView", "PromptsLibraryTab", and "TeamPromptsTab" React components
- Updated "ExtensionStateContext" to handle prompts catalog state and subscriptions
- Regist
- Adds a new "Prompts Library" view accessible via the sidebar
- Implements fetchs"-
2026-02-07 10:44:22 -08:00
32 changed files with 3080 additions and 65 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"cline": patch
---
Adds a new "Prompts Library" view
+12 -2
View File
@@ -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",
+74
View File
@@ -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);
}
+3
View File
@@ -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);
+4
View File
@@ -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)
})
})
})
+135
View File
@@ -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
+2
View File
@@ -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()))
+1
View File
@@ -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",
+284
View File
@@ -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
View File
@@ -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,
}
}
+18 -14
View File
@@ -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
+3
View File
@@ -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
+5 -1
View File
@@ -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
+8 -8
View File
@@ -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",
+101 -16
View File
@@ -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) =>