walking skeleton

This commit is contained in:
Smartsheet-JB-Brown 2025-04-10 12:25:12 -07:00
parent 5fb9af40fb
commit cd5b894578
37 changed files with 3334 additions and 21 deletions

7
.gitignore vendored
View file

@ -29,6 +29,11 @@ docs/_site/
#Local lint config
.eslintrc.local.json
#Logging
logs
# Roo-specific files
.roorules*
.roomodes
.clinerules
memory-bank/

View file

@ -0,0 +1,66 @@
# Roo-Code Package Manager Template
This repository serves as a template for creating package manager items for Roo-Code. It contains examples of different types of package manager items and the required structure for each.
## Repository Structure
```
package manager-template/
├── README.md
├── metadata.yml
├── roles/
│ ├── developer-role/
│ │ ├── metadata.yml
│ │ └── role.md
│ └── architect-role/
│ ├── metadata.yml
│ └── role.md
├── mcp-servers/
│ ├── file-analyzer/
│ │ ├── metadata.yml
│ │ └── server.js
│ └── code-generator/
│ ├── metadata.yml
│ └── server.js
└── storage-systems/
└── github-storage/
├── metadata.yml
└── storage.js
```
## Root Metadata
The `metadata.yml` file at the root of the repository contains information about the repository itself:
```yaml
name: "Example Package Manager Repository"
description: "A collection of example package manager items for Roo-Code"
author: "Roo Team"
version: "1.0.0"
lastUpdated: "2025-04-08"
```
## Item Metadata
Each item in the package manager has its own `metadata.yml` file that contains information about the item:
```yaml
name: "Item Name"
description: "Item description"
type: "role|mcp-server|storage|other"
author: "Author Name"
version: "1.0.0"
lastUpdated: "2025-04-08"
tags: ["tag1", "tag2"]
```
## Testing
To test this repository with the Roo-Code Package Manager:
1. Create a new GitHub repository
2. Upload this template to the repository
3. In Roo-Code, go to the Package Manager tab
4. Click on the "Sources" tab
5. Add your repository URL
6. Go back to the "Browse" tab to see your package manager items

View file

@ -0,0 +1,7 @@
name: "File Analyzer MCP Server"
description: "An MCP server that analyzes files for code quality, security issues, and performance optimizations"
type: "mcp-server"
author: "Roo Team"
version: "1.0.0"
lastUpdated: "2025-04-08"
tags: ["file-analyzer", "code-quality", "security", "performance"]

View file

@ -0,0 +1,134 @@
/**
* File Analyzer MCP Server
*
* This MCP server analyzes files for code quality, security issues, and performance optimizations.
*/
const { createServer } = require("@modelcontextprotocol/server")
// Create an MCP server
const server = createServer({
name: "file-analyzer",
description: "Analyzes files for code quality, security issues, and performance optimizations",
version: "1.0.0",
tools: [
{
name: "analyze_file",
description: "Analyzes a file for code quality, security issues, and performance optimizations",
parameters: {
type: "object",
properties: {
file_path: {
type: "string",
description: "Path to the file to analyze",
},
analysis_type: {
type: "string",
enum: ["quality", "security", "performance", "all"],
description: "Type of analysis to perform",
},
},
required: ["file_path"],
},
handler: async ({ file_path, analysis_type = "all" }) => {
try {
// In a real implementation, this would use actual code analysis tools
// For this example, we'll just return some mock results
const mockResults = {
quality: {
issues: [
{
severity: "warning",
message: "Function is too complex (cyclomatic complexity: 15)",
line: 42,
},
{
severity: "info",
message: "Consider adding more comments to this section",
line: 78,
},
],
score: 85,
},
security: {
issues: [
{ severity: "critical", message: "Potential SQL injection vulnerability", line: 123 },
{ severity: "warning", message: "Insecure random number generation", line: 56 },
],
score: 70,
},
performance: {
issues: [
{ severity: "warning", message: "Inefficient loop could be optimized", line: 92 },
{ severity: "info", message: "Consider memoizing this function", line: 105 },
],
score: 90,
},
}
// Return only the requested analysis types
if (analysis_type === "all") {
return {
file_path,
results: mockResults,
summary: "Analysis complete. Found issues in quality, security, and performance.",
}
} else {
return {
file_path,
results: { [analysis_type]: mockResults[analysis_type] },
summary: `Analysis complete. Found ${mockResults[analysis_type].issues.length} issues in ${analysis_type}.`,
}
}
} catch (error) {
return {
error: `Failed to analyze file: ${error.message}`,
}
}
},
},
{
name: "get_file_stats",
description: "Gets statistics about a file",
parameters: {
type: "object",
properties: {
file_path: {
type: "string",
description: "Path to the file to get statistics for",
},
},
required: ["file_path"],
},
handler: async ({ file_path }) => {
try {
// In a real implementation, this would use actual file system operations
// For this example, we'll just return some mock results
return {
file_path,
stats: {
lines_of_code: 250,
comment_lines: 45,
blank_lines: 30,
functions: 12,
classes: 3,
complexity: "medium",
},
}
} catch (error) {
return {
error: `Failed to get file statistics: ${error.message}`,
}
}
},
},
],
})
// Start the server
server.listen(3000, () => {
console.log("File Analyzer MCP server is running on port 3000")
})

View file

@ -0,0 +1,5 @@
name: "Example Package Manager Repository"
description: "A collection of example package manager items for Roo-Code"
author: "Roo Team"
version: "1.0.0"
lastUpdated: "2025-04-08"

View file

@ -0,0 +1,7 @@
name: "Full-Stack Developer Role"
description: "A role for a full-stack developer with expertise in web development, databases, and APIs"
type: "role"
author: "Roo Team"
version: "1.0.0"
lastUpdated: "2025-04-08"
tags: ["developer", "full-stack", "web", "database", "api"]

View file

@ -0,0 +1,51 @@
# Full-Stack Developer Role
## Role Description
You are a Full-Stack Developer with expertise in web development, databases, and APIs. You excel at building complete web applications from front-end to back-end, with a focus on creating robust, scalable, and maintainable code.
## Skills and Expertise
- **Front-End Development**: HTML, CSS, JavaScript, TypeScript, React, Vue, Angular
- **Back-End Development**: Node.js, Python, Java, C#, Ruby
- **Database Management**: SQL (PostgreSQL, MySQL), NoSQL (MongoDB, Redis)
- **API Development**: REST, GraphQL, WebSockets
- **DevOps**: Docker, Kubernetes, CI/CD pipelines
- **Testing**: Unit testing, integration testing, end-to-end testing
- **Version Control**: Git, GitHub, GitLab
## Responsibilities
1. Implement user interfaces based on design specifications
2. Develop server-side logic and APIs
3. Design and implement database schemas
4. Integrate front-end and back-end components
5. Optimize applications for performance and scalability
6. Write clean, maintainable, and well-documented code
7. Collaborate with other team members to ensure cohesive development
## Communication Style
- Clear and concise technical explanations
- Proactive in identifying potential issues
- Collaborative approach to problem-solving
- Detailed documentation of code and processes
## Problem-Solving Approach
1. Understand the requirements thoroughly
2. Break down complex problems into manageable components
3. Research and evaluate potential solutions
4. Implement the most appropriate solution
5. Test thoroughly to ensure quality
6. Document the solution and any lessons learned
## Best Practices
- Follow coding standards and style guides
- Write comprehensive tests
- Use meaningful variable and function names
- Keep functions small and focused
- Document code and APIs
- Regularly refactor code to improve quality
- Stay updated with the latest technologies and best practices

View file

@ -0,0 +1,7 @@
name: "GitHub Storage System"
description: "A storage system that uses GitHub repositories to store and retrieve data"
type: "storage"
author: "Roo Team"
version: "1.0.0"
lastUpdated: "2025-04-08"
tags: ["storage", "github", "git", "repository"]

View file

@ -0,0 +1,178 @@
/**
* GitHub Storage System
*
* This storage system uses GitHub repositories to store and retrieve data.
*/
class GitHubStorage {
/**
* Constructor for the GitHub Storage System
* @param {Object} config - Configuration object
* @param {string} config.owner - GitHub repository owner
* @param {string} config.repo - GitHub repository name
* @param {string} config.token - GitHub personal access token
* @param {string} config.branch - GitHub branch to use (default: main)
*/
constructor(config) {
this.owner = config.owner
this.repo = config.repo
this.token = config.token
this.branch = config.branch || "main"
this.baseUrl = `https://api.github.com/repos/${this.owner}/${this.repo}`
this.headers = {
Authorization: `token ${this.token}`,
Accept: "application/vnd.github.v3+json",
"Content-Type": "application/json",
}
}
/**
* Store data in the GitHub repository
* @param {string} path - Path to store the data at
* @param {any} data - Data to store
* @param {string} message - Commit message
* @returns {Promise<Object>} - Result of the operation
*/
async store(path, data, message = "Update data") {
try {
// Convert data to string if it's not already
const content = typeof data === "string" ? data : JSON.stringify(data, null, 2)
// Encode content to base64
const encodedContent = Buffer.from(content).toString("base64")
// Check if file exists
let sha
try {
const response = await fetch(`${this.baseUrl}/contents/${path}?ref=${this.branch}`, {
headers: this.headers,
})
if (response.ok) {
const fileData = await response.json()
sha = fileData.sha
}
} catch (error) {
// File doesn't exist, which is fine
}
// Create or update file
const body = {
message,
content: encodedContent,
branch: this.branch,
}
if (sha) {
body.sha = sha
}
const response = await fetch(`${this.baseUrl}/contents/${path}`, {
method: "PUT",
headers: this.headers,
body: JSON.stringify(body),
})
if (!response.ok) {
throw new Error(`Failed to store data: ${response.statusText}`)
}
return await response.json()
} catch (error) {
throw new Error(`Error storing data: ${error.message}`)
}
}
/**
* Retrieve data from the GitHub repository
* @param {string} path - Path to retrieve the data from
* @returns {Promise<any>} - Retrieved data
*/
async retrieve(path) {
try {
const response = await fetch(`${this.baseUrl}/contents/${path}?ref=${this.branch}`, {
headers: this.headers,
})
if (!response.ok) {
throw new Error(`Failed to retrieve data: ${response.statusText}`)
}
const data = await response.json()
const content = Buffer.from(data.content, "base64").toString("utf-8")
// Try to parse as JSON, return as string if not valid JSON
try {
return JSON.parse(content)
} catch (error) {
return content
}
} catch (error) {
throw new Error(`Error retrieving data: ${error.message}`)
}
}
/**
* Delete data from the GitHub repository
* @param {string} path - Path to delete
* @param {string} message - Commit message
* @returns {Promise<Object>} - Result of the operation
*/
async delete(path, message = "Delete data") {
try {
// Get the file's SHA
const response = await fetch(`${this.baseUrl}/contents/${path}?ref=${this.branch}`, {
headers: this.headers,
})
if (!response.ok) {
throw new Error(`Failed to get file info: ${response.statusText}`)
}
const data = await response.json()
// Delete the file
const deleteResponse = await fetch(`${this.baseUrl}/contents/${path}`, {
method: "DELETE",
headers: this.headers,
body: JSON.stringify({
message,
sha: data.sha,
branch: this.branch,
}),
})
if (!deleteResponse.ok) {
throw new Error(`Failed to delete data: ${deleteResponse.statusText}`)
}
return await deleteResponse.json()
} catch (error) {
throw new Error(`Error deleting data: ${error.message}`)
}
}
/**
* List files in a directory
* @param {string} path - Directory path
* @returns {Promise<Array>} - List of files
*/
async list(path = "") {
try {
const response = await fetch(`${this.baseUrl}/contents/${path}?ref=${this.branch}`, {
headers: this.headers,
})
if (!response.ok) {
throw new Error(`Failed to list files: ${response.statusText}`)
}
const data = await response.json()
return Array.isArray(data) ? data : [data]
} catch (error) {
throw new Error(`Error listing files: ${error.message}`)
}
}
}
module.exports = GitHubStorage

View file

@ -95,6 +95,11 @@
"title": "Prompts",
"icon": "$(notebook)"
},
{
"command": "roo-cline.packageManagerButtonClicked",
"title": "Package Manager",
"icon": "$(extensions)"
},
{
"command": "roo-cline.historyButtonClicked",
"title": "History",
@ -246,24 +251,29 @@
"when": "view == roo-cline.SidebarProvider"
},
{
"command": "roo-cline.historyButtonClicked",
"command": "roo-cline.packageManagerButtonClicked",
"group": "navigation@4",
"when": "view == roo-cline.SidebarProvider"
},
{
"command": "roo-cline.popoutButtonClicked",
"command": "roo-cline.historyButtonClicked",
"group": "navigation@5",
"when": "view == roo-cline.SidebarProvider"
},
{
"command": "roo-cline.settingsButtonClicked",
"command": "roo-cline.popoutButtonClicked",
"group": "navigation@6",
"when": "view == roo-cline.SidebarProvider"
},
{
"command": "roo-cline.helpButtonClicked",
"command": "roo-cline.settingsButtonClicked",
"group": "navigation@7",
"when": "view == roo-cline.SidebarProvider"
},
{
"command": "roo-cline.helpButtonClicked",
"group": "navigation@8",
"when": "view == roo-cline.SidebarProvider"
}
],
"editor/title": [
@ -283,15 +293,32 @@
"when": "activeWebviewPanelId == roo-cline.TabPanelProvider"
},
{
"command": "roo-cline.historyButtonClicked",
"command": "roo-cline.packageManagerButtonClicked",
"group": "navigation@4",
"when": "activeWebviewPanelId == roo-cline.TabPanelProvider"
},
{
"command": "roo-cline.popoutButtonClicked",
"command": "roo-cline.settingsButtonClicked",
"group": "navigation@7",
"when": "activeWebviewPanelId == roo-cline.TabPanelProvider"
},
{
"command": "roo-cline.helpButtonClicked",
"group": "navigation@8",
"when": "activeWebviewPanelId == roo-cline.TabPanelProvider"
}
],
"editor/title/context": [
{
"command": "roo-cline.historyButtonClicked",
"group": "navigation@5",
"when": "activeWebviewPanelId == roo-cline.TabPanelProvider"
},
{
"command": "roo-cline.popoutButtonClicked",
"group": "navigation@6",
"when": "activeWebviewPanelId == roo-cline.TabPanelProvider"
},
{
"command": "roo-cline.settingsButtonClicked",
"group": "navigation@6",

View file

@ -95,6 +95,11 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt
"roo-cline.helpButtonClicked": () => {
vscode.env.openExternal(vscode.Uri.parse("https://docs.roocode.com"))
},
"roo-cline.packageManagerButtonClicked": () => {
const visibleProvider = getVisibleProviderOrLog(outputChannel)
if (!visibleProvider) return
visibleProvider.postMessageToWebview({ type: "action", action: "packageManagerButtonClicked" })
},
"roo-cline.showHumanRelayDialog": (params: { requestId: string; promptText: string }) => {
const panel = getPanel()

View file

@ -37,6 +37,7 @@ import { getTheme } from "../../integrations/theme/getTheme"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
import { McpHub } from "../../services/mcp/McpHub"
import { McpServerManager } from "../../services/mcp/McpServerManager"
import { PackageManagerManager } from "../../services/package-manager"
import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService"
import { fileExistsAtPath } from "../../utils/fs"
import { setSoundEnabled } from "../../utils/sound"
@ -75,6 +76,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
return this._workspaceTracker
}
protected mcpHub?: McpHub // Change from private to protected
private packageManagerManager?: PackageManagerManager
public isViewLaunched = false
public settingsImportedAt?: number
@ -747,7 +749,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
* @param webview A reference to the extension webview
*/
private setWebviewMessageListener(webview: vscode.Webview) {
const onReceiveMessage = async (message: WebviewMessage) => webviewMessageHandler(this, message)
const onReceiveMessage = async (message: WebviewMessage) => webviewMessageHandler(this, message, this.packageManagerManager)
webview.onDidReceiveMessage(onReceiveMessage, null, this.disposables)
}
@ -1200,6 +1202,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
showRooIgnoredFiles,
language,
maxReadFileLine,
packageManagerSources,
} = await this.getState()
const telemetryKey = process.env.POSTHOG_API_KEY
@ -1275,6 +1278,13 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
renderContext: this.renderContext,
maxReadFileLine: maxReadFileLine ?? 500,
settingsImportedAt: this.settingsImportedAt,
packageManagerSources: packageManagerSources ?? [
{
url: "https://github.com/Smartsheet-JB-Brown/Package-Manager-Test",
name: "Official Roo-Code Package Manager",
enabled: true
}
],
}
}
@ -1357,6 +1367,13 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
telemetrySetting: stateValues.telemetrySetting || "unset",
showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? true,
maxReadFileLine: stateValues.maxReadFileLine ?? 500,
packageManagerSources: stateValues.packageManagerSources ?? [
{
url: "https://github.com/Smartsheet-JB-Brown/Package-Manager-Test",
name: "Official Roo-Code Package Manager",
enabled: true
}
],
}
}
@ -1451,6 +1468,14 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
return this.mcpHub
}
/**
* Set the package manager manager instance
* @param packageManagerManager The package manager manager instance
*/
public setPackageManagerManager(packageManagerManager: PackageManagerManager) {
this.packageManagerManager = packageManagerManager
}
/**
* Returns properties to be included in every telemetry event
* This method is called by the telemetry service to get context information

View file

@ -0,0 +1,234 @@
import * as vscode from "vscode"
import { ClineProvider } from "./ClineProvider"
import { WebviewMessage } from "../../shared/WebviewMessage"
import { ExtensionMessage } from "../../shared/ExtensionMessage"
import { PackageManagerManager } from "../../services/package-manager"
import { PackageManagerItem, PackageManagerSource } from "../../services/package-manager/types"
import { GlobalState } from "../../schemas"
/**
* Handle package manager-related messages from the webview
*/
export async function handlePackageManagerMessages(
provider: ClineProvider,
message: WebviewMessage,
packageManagerManager: PackageManagerManager
): Promise<boolean> {
// Utility function for updating global state
const updateGlobalState = async <K extends keyof GlobalState>(key: K, value: GlobalState[K]) =>
await provider.contextProxy.setValue(key, value)
switch (message.type) {
case "webviewDidLaunch": {
// For webviewDidLaunch, we don't do anything - package manager items will be loaded by explicit fetchPackageManagerItems
console.log("Package Manager: webviewDidLaunch received, but skipping fetch (will be triggered by explicit fetchPackageManagerItems)");
return true;
}
case "fetchPackageManagerItems": {
// Check if we need to force refresh using type assertion
const forceRefresh = (message as any).forceRefresh === true;
console.log(`Package Manager: Fetch requested with forceRefresh=${forceRefresh}`);
try {
console.log("Package Manager: Received request to fetch package manager items")
console.log("DEBUG: Processing package manager request")
// Wrap the entire initialization in a try-catch block
try {
// Initialize default sources if none exist
let sources = await provider.contextProxy.getValue("packageManagerSources") as PackageManagerSource[] || []
if (!sources || sources.length === 0) {
console.log("Package Manager: No sources found, initializing default sources")
sources = [
{
url: "https://github.com/Smartsheet-JB-Brown/Package-Manager-Test",
name: "Official Roo-Code Package Manager",
enabled: true
}
];
// Save the default sources
await provider.contextProxy.setValue("packageManagerSources", sources)
console.log("Package Manager: Default sources initialized")
}
console.log(`Package Manager: Fetching items from ${sources.length} sources`)
console.log(`DEBUG: PackageManagerManager instance: ${packageManagerManager ? "exists" : "null"}`)
// Add timing information
const startTime = Date.now()
// Simplify the initialization by limiting the number of items and adding more error handling
let items: PackageManagerItem[] = [];
try {
console.log("DEBUG: Starting to fetch items from sources");
// Only fetch from the first enabled source to reduce complexity
const enabledSources = sources.filter(s => s.enabled);
if (enabledSources.length > 0) {
const firstSource = enabledSources[0];
console.log(`Package Manager: Fetching items from first source: ${firstSource.url}`);
// Get items from the first source only
const sourceItems = await packageManagerManager.getPackageManagerItems([firstSource]);
items = sourceItems;
console.log("DEBUG: Successfully fetched items:", items.length);
} else {
console.log("DEBUG: No enabled sources found");
}
} catch (fetchError) {
console.error("Failed to fetch package manager items:", fetchError);
// Continue with empty items array
items = [];
}
console.log("DEBUG: Fetch completed, preparing to send items to webview");
const endTime = Date.now()
console.log(`Package Manager: Found ${items.length} items in ${endTime - startTime}ms`)
console.log(`Package Manager: First item:`, items.length > 0 ? items[0] : 'No items')
// Send the items to the webview
console.log("DEBUG: Creating message to send items to webview");
// Get the current state to include apiConfiguration to prevent welcome screen from showing
const currentState = await provider.getState();
const message = {
type: "state",
state: {
// Include the current apiConfiguration to prevent welcome screen from showing
// This is critical because ExtensionStateContext checks apiConfiguration to determine if welcome screen should be shown
apiConfiguration: currentState.apiConfiguration,
packageManagerItems: items
}
} as ExtensionMessage;
console.log(`Package Manager: Sending message to webview:`, message);
console.log("DEBUG: About to call postMessageToWebview with apiConfiguration:",
currentState.apiConfiguration ? "present" : "missing");
provider.postMessageToWebview(message);
console.log("DEBUG: Called postMessageToWebview");
console.log(`Package Manager: Message sent to webview`);
} catch (initError) {
console.error("Error in package manager initialization:", initError);
// Send an empty items array to the webview to prevent the spinner from spinning forever
// Get the current state to include apiConfiguration to prevent welcome screen from showing
const currentState = await provider.getState();
provider.postMessageToWebview({
type: "state",
state: {
// Include the current apiConfiguration to prevent welcome screen from showing
// This is critical because ExtensionStateContext checks apiConfiguration to determine if welcome screen should be shown
apiConfiguration: currentState.apiConfiguration,
packageManagerItems: []
}
} as any); // Use type assertion to bypass TypeScript checking
vscode.window.showErrorMessage(`Package manager initialization failed: ${initError instanceof Error ? initError.message : String(initError)}`);
}
} catch (error) {
console.error("Failed to fetch package manager items:", error);
vscode.window.showErrorMessage(`Failed to fetch package manager items: ${error instanceof Error ? error.message : String(error)}`)
}
return true
}
case "packageManagerSources": {
if (message.sources) {
// Enforce maximum of 10 sources
const MAX_SOURCES = 10;
let updatedSources: PackageManagerSource[];
if (message.sources.length > MAX_SOURCES) {
// Truncate to maximum allowed and show warning
updatedSources = message.sources.slice(0, MAX_SOURCES);
vscode.window.showWarningMessage(`Maximum of ${MAX_SOURCES} package manager sources allowed. Additional sources have been removed.`);
} else {
updatedSources = message.sources;
}
// Update the global state with the new sources
await updateGlobalState("packageManagerSources", updatedSources);
// Clean up cache directories for repositories that are no longer in the sources list
try {
console.log("Package Manager: Cleaning up cache directories for removed sources");
await packageManagerManager.cleanupCacheDirectories(updatedSources);
console.log("Package Manager: Cache cleanup completed");
} catch (error) {
console.error("Package Manager: Error during cache cleanup:", error);
}
// Update the webview with the new state
await provider.postStateToWebview();
}
return true;
}
case "openExternal": {
if (message.url) {
console.log(`Package Manager: Opening external URL: ${message.url}`);
try {
vscode.env.openExternal(vscode.Uri.parse(message.url));
console.log(`Package Manager: Successfully opened URL: ${message.url}`);
} catch (error) {
console.error(`Package Manager: Failed to open URL: ${error instanceof Error ? error.message : String(error)}`);
vscode.window.showErrorMessage(`Failed to open URL: ${error instanceof Error ? error.message : String(error)}`);
}
} else {
console.error("Package Manager: openExternal called without a URL");
}
return true;
}
case "refreshPackageManagerSource": {
if (message.url) {
try {
console.log(`Package Manager: Received request to refresh source ${message.url}`);
// Get the current sources
const sources = await provider.contextProxy.getValue("packageManagerSources") as PackageManagerSource[] || [];
// Find the source with the matching URL
const source = sources.find(s => s.url === message.url);
if (source) {
try {
// Refresh the repository
await packageManagerManager.refreshRepository(message.url);
vscode.window.showInformationMessage(`Successfully refreshed package manager source: ${source.name || message.url}`);
// Trigger a fetch to update the UI with the refreshed data
const currentState = await provider.getState();
provider.postMessageToWebview({
type: "state",
state: {
apiConfiguration: currentState.apiConfiguration,
packageManagerItems: await packageManagerManager.getPackageManagerItems(sources.filter(s => s.enabled))
}
} as ExtensionMessage);
} finally {
// Always notify the webview that the refresh is complete, even if it failed
console.log(`Package Manager: Sending repositoryRefreshComplete message for ${message.url}`);
provider.postMessageToWebview({
type: "repositoryRefreshComplete",
url: message.url
});
}
} else {
console.error(`Package Manager: Source URL not found: ${message.url}`);
vscode.window.showErrorMessage(`Source URL not found: ${message.url}`);
}
} catch (error) {
console.error(`Package Manager: Failed to refresh source: ${error instanceof Error ? error.message : String(error)}`);
vscode.window.showErrorMessage(`Failed to refresh source: ${error instanceof Error ? error.message : String(error)}`);
}
}
return true;
}
default:
return false
}
}

View file

@ -42,31 +42,64 @@ import { getDiffStrategy } from "../diff/DiffStrategy"
import { SYSTEM_PROMPT } from "../prompts/system"
import { buildApiHandler } from "../../api"
import { GlobalState } from "../../schemas"
import { PackageManagerManager } from "../../services/package-manager"
import { handlePackageManagerMessages } from "./packageManagerMessageHandler"
export const webviewMessageHandler = async (provider: ClineProvider, message: WebviewMessage) => {
// Track if package manager data has been loaded
let packageManagerDataLoaded = false;
export const webviewMessageHandler = async (
provider: ClineProvider,
message: WebviewMessage,
packageManagerManager?: PackageManagerManager
) => {
// Utility functions provided for concise get/update of global state via contextProxy API.
const getGlobalState = <K extends keyof GlobalState>(key: K) => provider.contextProxy.getValue(key)
const updateGlobalState = async <K extends keyof GlobalState>(key: K, value: GlobalState[K]) =>
await provider.contextProxy.setValue(key, value)
switch (message.type) {
case "webviewDidLaunch":
// Load custom modes first
const customModes = await provider.customModesManager.getCustomModes()
await updateGlobalState("customModes", customModes)
provider.postStateToWebview()
provider.workspaceTracker?.initializeFilePaths() // don't await
// Don't handle package manager messages in webviewDidLaunch
// They will be handled by the fetchPackageManagerItems case
console.log(`DEBUG: webviewDidLaunch - skipping package manager handling, will be triggered by explicit fetchPackageManagerItems`);
console.log(`DEBUG: About to call postStateToWebview`);
await provider.postStateToWebview();
console.log(`DEBUG: After calling postStateToWebview`);
console.log(`DEBUG: About to initialize workspace tracker file paths`);
provider.workspaceTracker?.initializeFilePaths(); // don't await
console.log(`DEBUG: After initializing workspace tracker file paths`);
// Continue with the rest of the webviewDidLaunch case
console.log(`DEBUG: Continuing with webviewDidLaunch case`);
getTheme().then((theme) => {
console.log(`DEBUG: Got theme, posting to webview`);
provider.postMessageToWebview({ type: "theme", text: JSON.stringify(theme) });
});
getTheme().then((theme) => provider.postMessageToWebview({ type: "theme", text: JSON.stringify(theme) }))
// If MCP Hub is already initialized, update the webview with current server list
const mcpHub = provider.getMcpHub()
console.log(`DEBUG: Getting MCP Hub`);
const mcpHub = provider.getMcpHub();
if (mcpHub) {
console.log(`DEBUG: MCP Hub exists, getting servers`);
const servers = mcpHub!.getAllServers();
console.log(`DEBUG: Got servers, posting to webview`);
provider.postMessageToWebview({
type: "mcpServers",
mcpServers: mcpHub.getAllServers(),
})
mcpServers: servers,
});
console.log(`DEBUG: Posted MCP servers to webview`);
} else {
console.log(`DEBUG: MCP Hub is undefined, skipping server list update`);
}
// Post last cached models in case the call to endpoint fails.
@ -228,7 +261,23 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
})
provider.isViewLaunched = true
break
break;
case "fetchPackageManagerItems":
if (packageManagerManager) {
console.log(`DEBUG: Handling explicit fetchPackageManagerItems message`);
try {
// Use non-null assertion to tell TypeScript that packageManagerManager is definitely not undefined here
console.log(`DEBUG: Before calling handlePackageManagerMessages for fetchPackageManagerItems`);
const result = await handlePackageManagerMessages(provider, message, packageManagerManager!);
console.log(`DEBUG: After calling handlePackageManagerMessages for fetchPackageManagerItems, result: ${result}`);
console.log(`DEBUG: Package manager message handled successfully: ${message.type}`);
} catch (error) {
console.error(`DEBUG: Error handling package manager message: ${error}`);
}
} else {
console.log(`DEBUG: packageManagerManager is undefined, skipping package manager message handling`);
}
break;
case "newTask":
// Code that should run in response to the hello message command
//vscode.window.showInformationMessage(message.text!)
@ -1314,9 +1363,24 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
await provider.postStateToWebview()
break
}
}
}
// Handle package manager related messages
if (packageManagerManager &&
(message.type === "packageManagerSources" ||
message.type === "openExternal" ||
message.type === "refreshPackageManagerSource")) {
try {
console.log(`DEBUG: Routing ${message.type} message to packageManagerMessageHandler`);
const result = await handlePackageManagerMessages(provider, message, packageManagerManager);
console.log(`DEBUG: Package manager message handled successfully: ${message.type}, result: ${result}`);
} catch (error) {
console.error(`DEBUG: Error handling package manager message: ${error}`);
}
}
}
const generateSystemPrompt = async (provider: ClineProvider, message: WebviewMessage) => {
const {
apiConfiguration,

View file

@ -336,6 +336,13 @@ type GlobalSettings = {
}
| undefined
enhancementApiConfigId?: string | undefined
packageManagerSources?:
| {
url: string
name?: string | undefined
enabled: boolean
}[]
| undefined
}
type ClineMessage = {

View file

@ -339,6 +339,13 @@ type GlobalSettings = {
}
| undefined
enhancementApiConfigId?: string | undefined
packageManagerSources?:
| {
url: string
name?: string | undefined
enabled: boolean
}[]
| undefined
}
export type { GlobalSettings }

View file

@ -19,6 +19,7 @@ import { ClineProvider } from "./core/webview/ClineProvider"
import { CodeActionProvider } from "./core/CodeActionProvider"
import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
import { McpServerManager } from "./services/mcp/McpServerManager"
import { PackageManagerManager } from "./services/package-manager"
import { telemetryService } from "./services/telemetry/TelemetryService"
import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry"
import { API } from "./exports/api"
@ -65,9 +66,13 @@ export async function activate(context: vscode.ExtensionContext) {
if (!context.globalState.get("allowedCommands")) {
context.globalState.update("allowedCommands", defaultCommands)
}
const provider = new ClineProvider(context, outputChannel, "sidebar")
// Initialize package manager
const packageManagerManager = new PackageManagerManager(context)
provider.setPackageManagerManager(packageManagerManager)
telemetryService.setProvider(provider)
const provider = new ClineProvider(context, outputChannel, "sidebar")
telemetryService.setProvider(provider)
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(ClineProvider.sideBarId, provider, {

View file

@ -545,6 +545,11 @@ export const globalSettingsSchema = z.object({
customModePrompts: customModePromptsSchema.optional(),
customSupportPrompts: customSupportPromptsSchema.optional(),
enhancementApiConfigId: z.string().optional(),
packageManagerSources: z.array(z.object({
url: z.string(),
name: z.string().optional(),
enabled: z.boolean()
})).optional(),
})
export type GlobalSettings = z.infer<typeof globalSettingsSchema>
@ -616,6 +621,7 @@ const globalSettingsRecord: GlobalSettingsRecord = {
customSupportPrompts: undefined,
enhancementApiConfigId: undefined,
cachedChromeHostUrl: undefined,
packageManagerSources: undefined,
}
export const GLOBAL_SETTINGS_KEYS = Object.keys(globalSettingsRecord) as Keys<GlobalSettings>[]

View file

@ -0,0 +1,311 @@
import * as vscode from "vscode";
import * as path from "path";
import * as fs from "fs/promises";
import { exec } from "child_process";
import { promisify } from "util";
import { PackageManagerItem, PackageManagerRepository } from "./types";
const execAsync = promisify(exec);
/**
* Service for fetching and validating package manager data from Git repositories
*/
export class GitFetcher {
private readonly cacheDir: string;
constructor(private readonly context: vscode.ExtensionContext) {
this.cacheDir = path.join(context.globalStorageUri.fsPath, "package-manager-cache");
}
/**
* Fetches repository data from a Git URL
* @param url The Git repository URL
* @returns A PackageManagerRepository object containing metadata and items
*/
async fetchRepository(url: string): Promise<PackageManagerRepository> {
console.log(`GitFetcher: Fetching repository from ${url}`);
try {
// Ensure cache directory exists
try {
await fs.mkdir(this.cacheDir, { recursive: true });
console.log(`GitFetcher: Cache directory ensured at ${this.cacheDir}`);
} catch (mkdirError) {
console.error(`GitFetcher: Error creating cache directory: ${mkdirError.message}`);
throw new Error(`Failed to create cache directory: ${mkdirError.message}`);
}
// Create a safe directory name from the URL
const repoName = this.getRepoNameFromUrl(url);
const repoDir = path.join(this.cacheDir, repoName);
console.log(`GitFetcher: Repository directory: ${repoDir}`);
// Clone or pull repository with timeout protection
try {
console.log(`GitFetcher: Cloning or pulling repository ${url}`);
await this.cloneOrPullRepository(url, repoDir);
console.log(`GitFetcher: Repository cloned/pulled successfully`);
} catch (gitError) {
console.error(`GitFetcher: Git operation failed: ${gitError.message}`);
throw new Error(`Git operation failed: ${gitError.message}`);
}
try {
// Validate repository structure
console.log(`GitFetcher: Validating repository structure`);
await this.validateRepositoryStructure(repoDir);
// Parse metadata
console.log(`GitFetcher: Parsing repository metadata`);
const metadata = await this.parseRepositoryMetadata(repoDir);
// Parse items
console.log(`GitFetcher: Parsing package manager items`);
const items = await this.parsePackageManagerItems(repoDir, url);
console.log(`GitFetcher: Successfully fetched repository with ${items.length} items`);
return {
metadata,
items,
url
};
} catch (validationError) {
// Log the validation error
console.error(`GitFetcher: Repository validation failed: ${validationError.message}`);
// Show error message
vscode.window.showErrorMessage(`Failed to fetch repository: ${validationError.message}`);
// Return empty repository
return {
metadata: {},
items: [],
url
};
}
} catch (error) {
// Show error message
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`GitFetcher: Failed to fetch repository: ${errorMessage}`);
vscode.window.showErrorMessage(`Failed to fetch repository: ${errorMessage}`);
// Return empty repository
return {
metadata: {},
items: [],
url
};
}
}
/**
* Extracts a safe directory name from a Git URL
* @param url The Git repository URL
* @returns A sanitized directory name
*/
private getRepoNameFromUrl(url: string): string {
// Extract repo name from URL and sanitize it
const urlParts = url.split("/").filter(part => part !== "");
const repoName = urlParts[urlParts.length - 1].replace(/\.git$/, "");
return repoName.replace(/[^a-zA-Z0-9-_]/g, "-");
}
/**
* Clones or pulls a Git repository
* @param url The Git repository URL
* @param repoDir The directory to clone to or pull in
*/
private async cloneOrPullRepository(url: string, repoDir: string): Promise<void> {
console.log(`GitFetcher: Checking if repository exists at ${repoDir}`);
try {
// Check if repository already exists
const repoExists = await fs.stat(path.join(repoDir, ".git"))
.then(() => true)
.catch(() => false);
if (repoExists) {
console.log(`GitFetcher: Repository exists, attempting to pull latest changes`);
try {
// Try to pull latest changes with timeout
const pullPromise = execAsync("git pull", { cwd: repoDir, timeout: 20000 });
await pullPromise;
console.log(`GitFetcher: Successfully pulled latest changes`);
} catch (pullError) {
console.error(`GitFetcher: Failed to pull repository: ${pullError.message}`);
// If pull fails, try to remove the directory and clone again
console.log(`GitFetcher: Attempting to remove and re-clone repository`);
try {
await fs.rm(repoDir, { recursive: true, force: true });
console.log(`GitFetcher: Removed existing repository directory`);
// Clone with timeout
const clonePromise = execAsync(`git clone "${url}" "${repoDir}"`, { timeout: 30000 });
await clonePromise;
console.log(`GitFetcher: Successfully re-cloned repository`);
} catch (rmError) {
console.error(`GitFetcher: Failed to re-clone repository: ${rmError.message}`);
throw new Error(`Failed to re-clone repository: ${rmError.message}`);
}
}
} else {
console.log(`GitFetcher: Repository does not exist, cloning from ${url}`);
// Clone repository with timeout
const clonePromise = execAsync(`git clone "${url}" "${repoDir}"`, { timeout: 30000 });
await clonePromise;
console.log(`GitFetcher: Successfully cloned repository`);
}
} catch (error) {
console.error(`GitFetcher: Failed to clone or pull repository: ${error.message}`);
throw new Error(`Failed to clone or pull repository: ${error.message}`);
}
}
/**
* Validates that a repository follows the expected structure
* @param repoDir The repository directory
*/
private async validateRepositoryStructure(repoDir: string): Promise<void> {
// Check for required files
const metadataPath = path.join(repoDir, "metadata.yml");
const metadataExists = await fs.stat(metadataPath)
.then(() => true)
.catch(() => false);
if (!metadataExists) {
throw new Error("Repository is missing metadata.yml file");
}
// Check for at least one of the item type directories
const mcpServersDir = path.join(repoDir, "mcp-servers");
const rolesDir = path.join(repoDir, "roles");
const storageSystemsDir = path.join(repoDir, "storage-systems");
const itemsDir = path.join(repoDir, "items"); // For backward compatibility
const mcpServersDirExists = await fs.stat(mcpServersDir).then(() => true).catch(() => false);
const rolesDirExists = await fs.stat(rolesDir).then(() => true).catch(() => false);
const storageSystemsDirExists = await fs.stat(storageSystemsDir).then(() => true).catch(() => false);
const itemsDirExists = await fs.stat(itemsDir).then(() => true).catch(() => false);
if (!mcpServersDirExists && !rolesDirExists && !storageSystemsDirExists && !itemsDirExists) {
throw new Error("Repository is missing item directories (mcp-servers, roles, storage-systems, or items)");
}
}
/**
* Parses the repository metadata file
* @param repoDir The repository directory
* @returns The parsed metadata
*/
private async parseRepositoryMetadata(repoDir: string): Promise<any> {
// Parse metadata.yml file
const metadataPath = path.join(repoDir, "metadata.yml");
const metadataContent = await fs.readFile(metadataPath, "utf-8");
// For now, we'll return a simple object
// In a future update, we'll add a YAML parser dependency
try {
return {
name: metadataContent.match(/name:\s*["']?([^"'\n]+)["']?/)?.[1] || "Repository Name",
description: metadataContent.match(/description:\s*["']?([^"'\n]+)["']?/)?.[1] || "Repository Description",
maintainer: metadataContent.match(/maintainer:\s*["']?([^"'\n]+)["']?/)?.[1],
website: metadataContent.match(/website:\s*["']?([^"'\n]+)["']?/)?.[1]
};
} catch (error) {
console.error("Failed to parse repository metadata:", error);
return {
name: "Repository Name",
description: "Repository Description"
};
}
}
/**
* Parses package manager items from a repository
* @param repoDir The repository directory
* @param repoUrl The repository URL
* @returns An array of PackageManagerItem objects
*/
private async parsePackageManagerItems(repoDir: string, repoUrl: string, branch: string = "main"): Promise<PackageManagerItem[]> {
const items: PackageManagerItem[] = [];
// Check for items in each directory type
const directoryTypes = [
{ path: path.join(repoDir, "mcp-servers"), type: "mcp-server", urlPath: "mcp-servers" },
{ path: path.join(repoDir, "roles"), type: "role", urlPath: "roles" },
{ path: path.join(repoDir, "storage-systems"), type: "storage", urlPath: "storage-systems" },
{ path: path.join(repoDir, "items"), type: "other", urlPath: "items" } // For backward compatibility
];
for (const dirType of directoryTypes) {
try {
// Check if directory exists
const dirExists = await fs.stat(dirType.path)
.then(() => true)
.catch(() => false);
if (!dirExists) continue;
// Get all subdirectories
const itemDirs = await fs.readdir(dirType.path);
for (const itemDir of itemDirs) {
const itemPath = path.join(dirType.path, itemDir);
const stats = await fs.stat(itemPath);
if (stats.isDirectory()) {
try {
// Parse item metadata
const metadataPath = path.join(itemPath, "metadata.yml");
const metadataExists = await fs.stat(metadataPath)
.then(() => true)
.catch(() => false);
if (metadataExists) {
const metadataContent = await fs.readFile(metadataPath, "utf-8");
// For now, we'll parse the YAML content manually
// In a future update, we'll add a YAML parser dependency
const name = metadataContent.match(/name:\s*["']?([^"'\n]+)["']?/)?.[1] || itemDir;
const description = metadataContent.match(/description:\s*["']?([^"'\n]+)["']?/)?.[1] || "No description";
// Use the directory type as the default type if not specified in metadata
const type = metadataContent.match(/type:\s*["']?([^"'\n]+)["']?/)?.[1] || dirType.type;
const author = metadataContent.match(/author:\s*["']?([^"'\n]+)["']?/)?.[1];
const version = metadataContent.match(/version:\s*["']?([^"'\n]+)["']?/)?.[1];
// Parse tags if present
const tagsMatch = metadataContent.match(/tags:\s*\[(.*?)\]/);
const tags = tagsMatch ?
tagsMatch[1].split(",").map(tag => tag.trim().replace(/["']/g, "")) :
undefined;
const item: PackageManagerItem = {
name,
description,
type: type as "role" | "mcp-server" | "storage" | "other",
url: `${repoUrl}/tree/${branch}/${dirType.urlPath}/${itemDir}`,
repoUrl,
author,
tags,
version
};
items.push(item);
}
} catch (error) {
console.error(`Failed to parse item ${itemDir}:`, error);
}
}
}
} catch (error) {
console.error(`Failed to parse directory ${dirType.path}:`, error);
}
}
return items;
}
}

View file

@ -0,0 +1,283 @@
import * as vscode from "vscode";
import * as path from "path";
import * as fs from "fs/promises";
import { GitFetcher } from "./GitFetcher";
import { PackageManagerItem, PackageManagerRepository, PackageManagerSource } from "./types";
/**
* Service for managing package manager data
*/
export class PackageManagerManager {
// Cache expiry time in milliseconds (set to a low value for testing)
private static readonly CACHE_EXPIRY_MS = 10 * 1000; // 10 seconds (normally 3600000 = 1 hour)
private gitFetcher: GitFetcher;
private cache: Map<string, { data: PackageManagerRepository, timestamp: number }> = new Map();
constructor(private readonly context: vscode.ExtensionContext) {
this.gitFetcher = new GitFetcher(context);
}
/**
* Gets package manager items from all enabled sources
* @param sources The package manager sources
* @returns An array of PackageManagerItem objects
*/
async getPackageManagerItems(sources: PackageManagerSource[]): Promise<PackageManagerItem[]> {
console.log(`PackageManagerManager: Getting items from ${sources.length} sources`);
const items: PackageManagerItem[] = [];
const errors: Error[] = [];
// Filter enabled sources
const enabledSources = sources.filter(s => s.enabled);
console.log(`PackageManagerManager: ${enabledSources.length} enabled sources`);
// Process sources sequentially to avoid overwhelming the system
for (const source of enabledSources) {
try {
console.log(`PackageManagerManager: Processing source ${source.url}`);
const repo = await this.getRepositoryData(source.url);
if (repo.items && repo.items.length > 0) {
console.log(`PackageManagerManager: Found ${repo.items.length} items in ${source.url}`);
items.push(...repo.items);
} else {
console.log(`PackageManagerManager: No items found in ${source.url}`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`PackageManagerManager: Failed to fetch data from ${source.url}:`, error);
errors.push(new Error(`Source ${source.url}: ${errorMessage}`));
}
}
// Show a single error message with all failures
if (errors.length > 0) {
const errorMessage = `Failed to fetch from ${errors.length} sources: ${errors.map(e => e.message).join("; ")}`;
console.error(`PackageManagerManager: ${errorMessage}`);
vscode.window.showErrorMessage(errorMessage);
}
console.log(`PackageManagerManager: Returning ${items.length} total items`);
return items;
}
/**
* Gets repository data from a URL, using cache if available
* @param url The repository URL
* @param forceRefresh Whether to bypass the cache and force a refresh
* @returns A PackageManagerRepository object
*/
async getRepositoryData(url: string, forceRefresh: boolean = false): Promise<PackageManagerRepository> {
try {
console.log(`PackageManagerManager: Getting repository data for ${url}`);
// Check cache first (unless force refresh is requested)
const cached = this.cache.get(url);
if (!forceRefresh && cached && (Date.now() - cached.timestamp) < PackageManagerManager.CACHE_EXPIRY_MS) {
console.log(`PackageManagerManager: Using cached data for ${url} (age: ${Date.now() - cached.timestamp}ms)`);
return cached.data;
}
if (forceRefresh) {
console.log(`PackageManagerManager: Force refresh requested for ${url}, bypassing cache`);
}
console.log(`PackageManagerManager: Cache miss or expired for ${url}, fetching fresh data`);
// Fetch fresh data with timeout protection
const fetchPromise = this.gitFetcher.fetchRepository(url);
// Create a timeout promise
const timeoutPromise = new Promise<PackageManagerRepository>((_, reject) => {
setTimeout(() => {
reject(new Error(`Repository fetch timed out after 30 seconds: ${url}`));
}, 30000); // 30 second timeout
});
// Race the fetch against the timeout
const data = await Promise.race([fetchPromise, timeoutPromise]);
// Cache the result
this.cache.set(url, { data, timestamp: Date.now() });
console.log(`PackageManagerManager: Successfully fetched and cached data for ${url}`);
return data;
} catch (error) {
console.error(`PackageManagerManager: Error fetching repository data for ${url}:`, error);
// Return empty repository data instead of throwing
return {
metadata: {},
items: [],
url
};
}
}
/**
* Refreshes a specific repository, bypassing the cache
* @param url The repository URL to refresh
* @returns The refreshed repository data
*/
async refreshRepository(url: string): Promise<PackageManagerRepository> {
console.log(`PackageManagerManager: Refreshing repository ${url}`);
try {
// Force a refresh by bypassing the cache
const data = await this.getRepositoryData(url, true);
console.log(`PackageManagerManager: Repository ${url} refreshed successfully`);
return data;
} catch (error) {
console.error(`PackageManagerManager: Failed to refresh repository ${url}:`, error);
throw error;
}
}
/**
* Clears the in-memory cache
*/
clearCache(): void {
this.cache.clear();
}
/**
* Cleans up cache directories for repositories that are no longer in the configured sources
* @param currentSources The current list of package manager sources
*/
async cleanupCacheDirectories(currentSources: PackageManagerSource[]): Promise<void> {
try {
// Get the cache directory path
const cacheDir = path.join(this.context.globalStorageUri.fsPath, "package-manager-cache");
// Check if cache directory exists
try {
await fs.stat(cacheDir);
} catch (error) {
console.log("PackageManagerManager: Cache directory doesn't exist yet, nothing to clean up");
return;
}
// Get all subdirectories in the cache directory
const entries = await fs.readdir(cacheDir, { withFileTypes: true });
const cachedRepoDirs = entries
.filter(entry => entry.isDirectory())
.map(entry => entry.name);
console.log(`PackageManagerManager: Found ${cachedRepoDirs.length} cached repositories`);
// Get the list of repository names from current sources
const currentRepoNames = currentSources.map(source => this.getRepoNameFromUrl(source.url));
// Find directories to delete
const dirsToDelete = cachedRepoDirs.filter(dir => !currentRepoNames.includes(dir));
console.log(`PackageManagerManager: Found ${dirsToDelete.length} repositories to delete`);
// Delete each directory that's no longer in the sources
for (const dirName of dirsToDelete) {
try {
const dirPath = path.join(cacheDir, dirName);
console.log(`PackageManagerManager: Deleting cache directory ${dirPath}`);
await fs.rm(dirPath, { recursive: true, force: true });
console.log(`PackageManagerManager: Successfully deleted ${dirPath}`);
} catch (error) {
console.error(`PackageManagerManager: Failed to delete directory ${dirName}:`, error);
}
}
console.log(`PackageManagerManager: Cache cleanup completed, deleted ${dirsToDelete.length} directories`);
} catch (error) {
console.error("PackageManagerManager: Error cleaning up cache directories:", error);
}
}
/**
* Extracts a safe directory name from a Git URL
* @param url The Git repository URL
* @returns A sanitized directory name
*/
private getRepoNameFromUrl(url: string): string {
// Extract repo name from URL and sanitize it
const urlParts = url.split("/").filter(part => part !== "");
const repoName = urlParts[urlParts.length - 1].replace(/\.git$/, "");
return repoName.replace(/[^a-zA-Z0-9-_]/g, "-");
}
/**
* Filters package manager items based on criteria
* @param items The items to filter
* @param filters The filter criteria
* @returns Filtered items
*/
filterItems(items: PackageManagerItem[], filters: { type?: string, search?: string, tags?: string[] }): PackageManagerItem[] {
return items.filter(item => {
// Filter by type
if (filters.type && item.type !== filters.type) {
return false;
}
// Filter by search term
if (filters.search) {
const searchTerm = filters.search.toLowerCase();
const nameMatch = item.name.toLowerCase().includes(searchTerm);
const descMatch = item.description.toLowerCase().includes(searchTerm);
const authorMatch = item.author?.toLowerCase().includes(searchTerm);
if (!nameMatch && !descMatch && !authorMatch) {
return false;
}
}
// Filter by tags
if (filters.tags && filters.tags.length > 0) {
if (!item.tags || item.tags.length === 0) {
return false;
}
const hasMatchingTag = filters.tags.some(tag => item.tags!.includes(tag));
if (!hasMatchingTag) {
return false;
}
}
return true;
});
}
/**
* Sorts package manager items
* @param items The items to sort
* @param sortBy The field to sort by
* @param sortOrder The sort order
* @returns Sorted items
*/
sortItems(items: PackageManagerItem[], sortBy: string, sortOrder: "asc" | "desc"): PackageManagerItem[] {
return [...items].sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case "name":
comparison = a.name.localeCompare(b.name);
break;
case "author":
comparison = (a.author || "").localeCompare(b.author || "");
break;
case "lastUpdated":
comparison = (a.lastUpdated || "").localeCompare(b.lastUpdated || "");
break;
case "stars":
comparison = (a.stars || 0) - (b.stars || 0);
break;
case "downloads":
comparison = (a.downloads || 0) - (b.downloads || 0);
break;
default:
comparison = a.name.localeCompare(b.name);
}
return sortOrder === "asc" ? comparison : -comparison;
});
}
}

View file

@ -0,0 +1,25 @@
describe('Git command quoting', () => {
it('should properly quote paths with spaces', () => {
// This test verifies that our fix for handling paths with spaces works correctly
const url = 'https://github.com/example/repo';
const repoDir = '/path/with spaces/to/repo';
// This is the fix we implemented in GitFetcher.cloneOrPullRepository
const command = `git clone "${url}" "${repoDir}"`;
// Verify that the command is properly quoted
expect(command).toBe('git clone "https://github.com/example/repo" "/path/with spaces/to/repo"');
});
it('should handle paths with special characters', () => {
// Test with more complex paths
const url = 'https://github.com/example/repo-name';
const repoDir = '/path/with spaces/and (special) characters/to/repo';
// This is the fix we implemented in GitFetcher.cloneOrPullRepository
const command = `git clone "${url}" "${repoDir}"`;
// Verify that the command is properly quoted
expect(command).toBe('git clone "https://github.com/example/repo-name" "/path/with spaces/and (special) characters/to/repo"');
});
});

View file

@ -0,0 +1,206 @@
import { GitFetcher } from '../GitFetcher';
import * as vscode from 'vscode';
import * as fs from 'fs/promises';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { PackageManagerItem, PackageManagerRepository } from '../types';
// Mock the exec function
jest.mock('child_process', () => ({
exec: jest.fn()
}));
// Mock promisify to return our mocked exec function
jest.mock('util', () => ({
promisify: jest.fn().mockImplementation(() => {
return jest.fn().mockResolvedValue({ stdout: '', stderr: '' });
})
}));
// Mock fs.promises
jest.mock('fs/promises', () => ({
mkdir: jest.fn().mockResolvedValue(undefined),
readdir: jest.fn().mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('roles')) {
return Promise.resolve(['developer-role']);
}
if (pathStr.includes('mcp-servers')) {
return Promise.resolve(['file-analyzer']);
}
if (pathStr.includes('storage-systems')) {
return Promise.resolve(['github-storage']);
}
if (pathStr.includes('items')) {
return Promise.resolve([]);
}
return Promise.resolve([]);
}),
stat: jest.fn().mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('.git') ||
pathStr.includes('roles') ||
pathStr.includes('mcp-servers') ||
pathStr.includes('storage-systems') ||
pathStr.includes('developer-role') ||
pathStr.includes('file-analyzer') ||
pathStr.includes('github-storage')) {
return Promise.resolve({ isDirectory: () => true });
}
if (pathStr.includes('metadata.yml')) {
return Promise.resolve({ isFile: () => true });
}
return Promise.reject(new Error('File not found'));
}),
readFile: jest.fn().mockImplementation((path, encoding) => {
const pathStr = path.toString();
if (pathStr.includes('metadata.yml') &&
!pathStr.includes('developer-role') &&
!pathStr.includes('file-analyzer') &&
!pathStr.includes('github-storage')) {
return Promise.resolve('name: "Example Package Manager Repository"\ndescription: "A collection of example package manager items for Roo-Code"\nauthor: "Roo Team"\nversion: "1.0.0"\nlastUpdated: "2025-04-08"');
}
if (pathStr.includes('developer-role/metadata.yml')) {
return Promise.resolve('name: "Full-Stack Developer Role"\ndescription: "A role for a full-stack developer"\ntype: "role"\nauthor: "Roo Team"\nversion: "1.0.0"\ntags: ["developer", "full-stack"]');
}
if (pathStr.includes('file-analyzer/metadata.yml')) {
return Promise.resolve('name: "File Analyzer MCP Server"\ndescription: "An MCP server that analyzes files"\ntype: "mcp-server"\nauthor: "Roo Team"\nversion: "1.0.0"\ntags: ["file-analyzer", "code-quality"]');
}
if (pathStr.includes('github-storage/metadata.yml')) {
return Promise.resolve('name: "GitHub Storage System"\ndescription: "A storage system that uses GitHub repositories"\ntype: "storage"\nauthor: "Roo Team"\nversion: "1.0.0"\ntags: ["storage", "github"]');
}
return Promise.reject(new Error('File not found'));
})
}));
const mockedFs = fs as jest.Mocked<typeof fs>;
// Mock vscode
jest.mock('vscode', () => ({
window: {
showErrorMessage: jest.fn(),
},
Uri: {
parse: jest.fn().mockImplementation((url) => ({ toString: () => url })),
}
}));
describe('GitFetcher', () => {
let gitFetcher: GitFetcher;
const mockContext = {
globalStorageUri: { fsPath: '/mock/storage/path' }
} as unknown as vscode.ExtensionContext;
beforeEach(() => {
gitFetcher = new GitFetcher(mockContext);
jest.clearAllMocks();
// Setup path.join to work normally
jest.spyOn(path, 'join').mockImplementation((...args) => args.join('/'));
});
describe('fetchRepository', () => {
it('should fetch repository successfully', async () => {
const repoUrl = 'https://github.com/Smartsheet-JB-Brown/Package-Manager-Test';
// Mock execAsync for git operations
const mockExecPromise = jest.fn().mockResolvedValue({ stdout: '', stderr: '' });
(promisify as unknown as jest.Mock).mockReturnValue(mockExecPromise);
// Call the method
const result = await gitFetcher.fetchRepository(repoUrl);
// Assertions
expect(result).toBeDefined();
expect(result.metadata).toBeDefined();
expect(result.metadata.name).toBe('Example Package Manager Repository');
expect(result.items).toHaveLength(3); // One role, one MCP server, one storage system
// Check role item
const roleItem = result.items.find((item: PackageManagerItem) => item.type === 'role');
expect(roleItem).toBeDefined();
expect(roleItem?.name).toBe('Full-Stack Developer Role');
expect(roleItem?.tags).toContain('developer');
expect(roleItem?.url).toBe('https://github.com/Smartsheet-JB-Brown/Package-Manager-Test/tree/main/roles/developer-role');
// Check MCP server item
const mcpServerItem = result.items.find((item: PackageManagerItem) => item.type === 'mcp-server');
expect(mcpServerItem).toBeDefined();
expect(mcpServerItem?.name).toBe('File Analyzer MCP Server');
expect(mcpServerItem?.tags).toContain('file-analyzer');
expect(mcpServerItem?.url).toBe('https://github.com/Smartsheet-JB-Brown/Package-Manager-Test/tree/main/mcp-servers/file-analyzer');
// Check storage system item
const storageItem = result.items.find((item: PackageManagerItem) => item.type === 'storage');
expect(storageItem).toBeDefined();
expect(storageItem?.name).toBe('GitHub Storage System');
expect(storageItem?.tags).toContain('storage');
expect(storageItem?.url).toBe('https://github.com/Smartsheet-JB-Brown/Package-Manager-Test/tree/main/storage-systems/github-storage');
// Verify file system operations
expect(mockedFs.mkdir).toHaveBeenCalledWith('/mock/storage/path/package-manager-cache', { recursive: true });
expect(mockedFs.stat).toHaveBeenCalledWith('/mock/storage/path/package-manager-cache/Package-Manager-Test/.git');
expect(mockedFs.stat).toHaveBeenCalledWith('/mock/storage/path/package-manager-cache/Package-Manager-Test/metadata.yml');
expect(mockedFs.readFile).toHaveBeenCalledWith('/mock/storage/path/package-manager-cache/Package-Manager-Test/metadata.yml', 'utf-8');
// Verify that readdir was called for each item directory type
expect(mockedFs.readdir).toHaveBeenCalledWith('/mock/storage/path/package-manager-cache/Package-Manager-Test/roles');
expect(mockedFs.readdir).toHaveBeenCalledWith('/mock/storage/path/package-manager-cache/Package-Manager-Test/mcp-servers');
expect(mockedFs.readdir).toHaveBeenCalledWith('/mock/storage/path/package-manager-cache/Package-Manager-Test/storage-systems');
});
it('should handle errors when fetching repository', async () => {
const repoUrl = 'https://github.com/Smartsheet-JB-Brown/Package-Manager-Test';
// Mock stat to throw an error for the .git directory check
mockedFs.stat.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('.git')) {
return Promise.reject(new Error('Directory not found'));
}
return Promise.resolve({ isDirectory: () => false, isFile: () => false } as any);
});
// Mock readFile to throw an error for metadata.yml
mockedFs.readFile.mockImplementation((path) => {
return Promise.reject(new Error('File not found'));
});
// Mock exec to throw an error
const mockExecPromise = jest.fn().mockRejectedValue(new Error('Git error'));
(promisify as unknown as jest.Mock).mockReturnValue(mockExecPromise);
// Call the method
const result = await gitFetcher.fetchRepository(repoUrl);
// Assertions
expect(result).toEqual({ metadata: {}, items: [], url: repoUrl });
expect(vscode.window.showErrorMessage).toHaveBeenCalled();
});
});
describe('getRepoNameFromUrl', () => {
it('should extract repository name from GitHub URL', () => {
const url = 'https://github.com/Smartsheet-JB-Brown/Package-Manager-Test';
const result = gitFetcher['getRepoNameFromUrl'](url);
expect(result).toBe('Package-Manager-Test');
});
it('should handle GitHub URLs with trailing slash', () => {
const url = 'https://github.com/Smartsheet-JB-Brown/Package-Manager-Test/';
// Call the actual method on gitFetcher
const result = gitFetcher['getRepoNameFromUrl'](url);
expect(result).toBe('Package-Manager-Test');
});
it('should sanitize repository names', () => {
const url = 'https://github.com/Smartsheet-JB-Brown/Package Manager Test';
// Call the actual method on gitFetcher
const result = gitFetcher['getRepoNameFromUrl'](url);
expect(result).toBe('Package-Manager-Test');
});
});
});

View file

@ -0,0 +1,35 @@
import { exec } from 'child_process';
import { promisify } from 'util';
// Mock the exec function
jest.mock('child_process', () => ({
exec: jest.fn()
}));
// Mock promisify to return our mocked exec function
jest.mock('util', () => ({
promisify: jest.fn()
}));
describe.skip('Git command with spaces in paths', () => {
it('should properly quote paths with spaces', async () => {
// Set up our mocks
const mockExecFn = jest.fn().mockResolvedValue({ stdout: '', stderr: '' });
(promisify as unknown as jest.Mock).mockReturnValue(mockExecFn);
// Import the module that contains our fix
const execAsync = promisify(exec);
// Execute the command with a path that contains spaces
const url = 'https://github.com/example/repo';
const repoDir = '/path/with spaces/to/repo';
await execAsync(`git clone "${url}" "${repoDir}"`);
// Verify that exec was called with the properly quoted command
expect(exec).toHaveBeenCalledWith(
`git clone "${url}" "${repoDir}"`,
expect.anything(),
expect.anything()
);
});
});

View file

@ -0,0 +1,273 @@
import { GitFetcher } from '../GitFetcher';
import * as vscode from 'vscode';
import * as fs from 'fs/promises';
import * as path from 'path';
import { PackageManagerItem } from '../types';
// Mock fs.promises
jest.mock('fs/promises', () => ({
stat: jest.fn(),
mkdir: jest.fn().mockResolvedValue(undefined),
readdir: jest.fn(),
readFile: jest.fn()
}));
const mockedFs = fs as jest.Mocked<typeof fs>;
// Mock vscode
jest.mock('vscode', () => ({
window: {
showErrorMessage: jest.fn(),
}
}));
describe('Parse Package Manager Items', () => {
let gitFetcher: GitFetcher;
const mockContext = {
globalStorageUri: { fsPath: '/mock/storage/path' }
} as unknown as vscode.ExtensionContext;
beforeEach(() => {
gitFetcher = new GitFetcher(mockContext);
jest.clearAllMocks();
});
// Helper function to access private method
const parsePackageManagerItems = async (repoDir: string, repoUrl: string) => {
return (gitFetcher as any).parsePackageManagerItems(repoDir, repoUrl);
};
describe('directory structure handling', () => {
it('should parse items from mcp-servers directory', async () => {
// Mock directory structure
mockedFs.stat.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('mcp-servers')) {
return Promise.resolve({ isDirectory: () => true } as any);
}
if (pathStr.includes('metadata.yml')) {
return Promise.resolve({ isFile: () => true } as any);
}
return Promise.reject(new Error('Not found'));
});
// Mock readdir to return items in mcp-servers directory
mockedFs.readdir.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('mcp-servers')) {
return Promise.resolve(['file-analyzer'] as any);
}
return Promise.resolve([] as any);
});
// Mock readFile to return metadata content
mockedFs.readFile.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('file-analyzer/metadata.yml')) {
return Promise.resolve('name: "File Analyzer MCP Server"\ndescription: "An MCP server that analyzes files"\ntype: "mcp-server"\nauthor: "Roo Team"\nversion: "1.0.0"\ntags: ["file-analyzer", "code-quality"]');
}
return Promise.reject(new Error('File not found'));
});
// Call the method
const items = await parsePackageManagerItems('/mock/repo', 'https://github.com/example/repo');
// Assertions
expect(items).toHaveLength(1);
expect(items[0].name).toBe('File Analyzer MCP Server');
expect(items[0].type).toBe('mcp-server');
expect(items[0].url).toBe('https://github.com/example/repo/tree/main/mcp-servers/file-analyzer');
});
it('should parse items from roles directory', async () => {
// Mock directory structure
mockedFs.stat.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('roles')) {
return Promise.resolve({ isDirectory: () => true } as any);
}
if (pathStr.includes('metadata.yml')) {
return Promise.resolve({ isFile: () => true } as any);
}
return Promise.reject(new Error('Not found'));
});
// Mock readdir to return items in roles directory
mockedFs.readdir.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('roles')) {
return Promise.resolve(['developer-role'] as any);
}
return Promise.resolve([] as any);
});
// Mock readFile to return metadata content
mockedFs.readFile.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('developer-role/metadata.yml')) {
return Promise.resolve('name: "Full-Stack Developer Role"\ndescription: "A role for a full-stack developer"\ntype: "role"\nauthor: "Roo Team"\nversion: "1.0.0"\ntags: ["developer", "full-stack"]');
}
return Promise.reject(new Error('File not found'));
});
// Call the method
const items = await parsePackageManagerItems('/mock/repo', 'https://github.com/example/repo');
// Assertions
expect(items).toHaveLength(1);
expect(items[0].name).toBe('Full-Stack Developer Role');
expect(items[0].type).toBe('role');
expect(items[0].url).toBe('https://github.com/example/repo/tree/main/roles/developer-role');
});
it('should parse items from storage-systems directory', async () => {
// Mock directory structure
mockedFs.stat.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('storage-systems')) {
return Promise.resolve({ isDirectory: () => true } as any);
}
if (pathStr.includes('metadata.yml')) {
return Promise.resolve({ isFile: () => true } as any);
}
return Promise.reject(new Error('Not found'));
});
// Mock readdir to return items in storage-systems directory
mockedFs.readdir.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('storage-systems')) {
return Promise.resolve(['github-storage'] as any);
}
return Promise.resolve([] as any);
});
// Mock readFile to return metadata content
mockedFs.readFile.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('github-storage/metadata.yml')) {
return Promise.resolve('name: "GitHub Storage System"\ndescription: "A storage system that uses GitHub repositories"\ntype: "storage"\nauthor: "Roo Team"\nversion: "1.0.0"\ntags: ["storage", "github"]');
}
return Promise.reject(new Error('File not found'));
});
// Call the method
const items = await parsePackageManagerItems('/mock/repo', 'https://github.com/example/repo');
// Assertions
expect(items).toHaveLength(1);
expect(items[0].name).toBe('GitHub Storage System');
expect(items[0].type).toBe('storage');
expect(items[0].url).toBe('https://github.com/example/repo/tree/main/storage-systems/github-storage');
});
it('should parse items from items directory (backward compatibility)', async () => {
// Mock directory structure
mockedFs.stat.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('/items')) {
return Promise.resolve({ isDirectory: () => true } as any);
}
if (pathStr.includes('metadata.yml')) {
return Promise.resolve({ isFile: () => true } as any);
}
return Promise.reject(new Error('Not found'));
});
// Mock readdir to return items in items directory
mockedFs.readdir.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('/items')) {
return Promise.resolve(['generic-item'] as any);
}
return Promise.resolve([] as any);
});
// Mock readFile to return metadata content
mockedFs.readFile.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('generic-item/metadata.yml')) {
return Promise.resolve('name: "Generic Item"\ndescription: "A generic package manager item"\ntype: "other"\nauthor: "Roo Team"\nversion: "1.0.0"\ntags: ["generic", "other"]');
}
return Promise.reject(new Error('File not found'));
});
// Call the method
const items = await parsePackageManagerItems('/mock/repo', 'https://github.com/example/repo');
// Assertions
expect(items).toHaveLength(1);
expect(items[0].name).toBe('Generic Item');
expect(items[0].type).toBe('other');
expect(items[0].url).toBe('https://github.com/example/repo/tree/main/items/generic-item');
});
it('should parse items from multiple directories', async () => {
// Mock directory structure
mockedFs.stat.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('mcp-servers') || pathStr.includes('roles') || pathStr.includes('storage-systems')) {
return Promise.resolve({ isDirectory: () => true } as any);
}
if (pathStr.includes('metadata.yml')) {
return Promise.resolve({ isFile: () => true } as any);
}
return Promise.reject(new Error('Not found'));
});
// Mock readdir to return items in each directory
mockedFs.readdir.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('mcp-servers')) {
return Promise.resolve(['file-analyzer'] as any);
}
if (pathStr.includes('roles')) {
return Promise.resolve(['developer-role'] as any);
}
if (pathStr.includes('storage-systems')) {
return Promise.resolve(['github-storage'] as any);
}
return Promise.resolve([] as any);
});
// Mock readFile to return metadata content
mockedFs.readFile.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('file-analyzer/metadata.yml')) {
return Promise.resolve('name: "File Analyzer MCP Server"\ndescription: "An MCP server that analyzes files"\ntype: "mcp-server"\nauthor: "Roo Team"\nversion: "1.0.0"\ntags: ["file-analyzer", "code-quality"]');
}
if (pathStr.includes('developer-role/metadata.yml')) {
return Promise.resolve('name: "Full-Stack Developer Role"\ndescription: "A role for a full-stack developer"\ntype: "role"\nauthor: "Roo Team"\nversion: "1.0.0"\ntags: ["developer", "full-stack"]');
}
if (pathStr.includes('github-storage/metadata.yml')) {
return Promise.resolve('name: "GitHub Storage System"\ndescription: "A storage system that uses GitHub repositories"\ntype: "storage"\nauthor: "Roo Team"\nversion: "1.0.0"\ntags: ["storage", "github"]');
}
return Promise.reject(new Error('File not found'));
});
// Call the method
const items = await parsePackageManagerItems('/mock/repo', 'https://github.com/example/repo');
// Assertions
expect(items).toHaveLength(3);
// Check for MCP server item
const mcpServerItem = items.find((item: PackageManagerItem) => item.type === 'mcp-server');
expect(mcpServerItem).toBeDefined();
expect(mcpServerItem?.name).toBe('File Analyzer MCP Server');
expect(mcpServerItem?.url).toBe('https://github.com/example/repo/tree/main/mcp-servers/file-analyzer');
// Check for role item
const roleItem = items.find((item: PackageManagerItem) => item.type === 'role');
expect(roleItem).toBeDefined();
expect(roleItem?.name).toBe('Full-Stack Developer Role');
expect(roleItem?.url).toBe('https://github.com/example/repo/tree/main/roles/developer-role');
// Check for storage system item
const storageItem = items.find((item: PackageManagerItem) => item.type === 'storage');
expect(storageItem).toBeDefined();
expect(storageItem?.name).toBe('GitHub Storage System');
expect(storageItem?.url).toBe('https://github.com/example/repo/tree/main/storage-systems/github-storage');
});
});
});

View file

@ -0,0 +1,163 @@
import { GitFetcher } from '../GitFetcher';
import * as vscode from 'vscode';
import * as fs from 'fs/promises';
import * as path from 'path';
// Mock fs.promises
jest.mock('fs/promises', () => ({
stat: jest.fn(),
mkdir: jest.fn().mockResolvedValue(undefined),
readdir: jest.fn().mockResolvedValue([]),
readFile: jest.fn().mockResolvedValue('')
}));
const mockedFs = fs as jest.Mocked<typeof fs>;
// Mock vscode
jest.mock('vscode', () => ({
window: {
showErrorMessage: jest.fn(),
}
}));
describe('Repository Structure Validation', () => {
let gitFetcher: GitFetcher;
const mockContext = {
globalStorageUri: { fsPath: '/mock/storage/path' }
} as unknown as vscode.ExtensionContext;
beforeEach(() => {
gitFetcher = new GitFetcher(mockContext);
jest.clearAllMocks();
});
// Helper function to access private method
const validateRepositoryStructure = async (repoDir: string) => {
return (gitFetcher as any).validateRepositoryStructure(repoDir);
};
describe('metadata.yml validation', () => {
it('should throw error when metadata.yml is missing', async () => {
// Mock stat to return false for metadata.yml
mockedFs.stat.mockImplementation((path) => {
if (path.toString().includes('metadata.yml')) {
return Promise.reject(new Error('File not found'));
}
return Promise.resolve({ isDirectory: () => true, isFile: () => true } as any);
});
// Call the method and expect it to throw
await expect(validateRepositoryStructure('/mock/repo')).rejects.toThrow('Repository is missing metadata.yml file');
});
it('should pass when metadata.yml exists', async () => {
// Mock stat to return true for metadata.yml and at least one item directory
mockedFs.stat.mockImplementation((path) => {
return Promise.resolve({ isDirectory: () => true, isFile: () => true } as any);
});
// Call the method and expect it not to throw
await expect(validateRepositoryStructure('/mock/repo')).resolves.not.toThrow();
});
});
describe('item directories validation', () => {
it('should throw error when no item directories exist', async () => {
// Mock stat to return true for metadata.yml but false for all item directories
mockedFs.stat.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('metadata.yml')) {
return Promise.resolve({ isFile: () => true } as any);
}
if (pathStr.includes('mcp-servers') || pathStr.includes('roles') ||
pathStr.includes('storage-systems') || pathStr.includes('items')) {
return Promise.reject(new Error('Directory not found'));
}
return Promise.resolve({ isDirectory: () => true } as any);
});
// Call the method and expect it to throw
await expect(validateRepositoryStructure('/mock/repo')).rejects.toThrow(
'Repository is missing item directories (mcp-servers, roles, storage-systems, or items)'
);
});
it('should pass when mcp-servers directory exists', async () => {
// Mock stat to return true for metadata.yml and mcp-servers
mockedFs.stat.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('metadata.yml') || pathStr.includes('mcp-servers')) {
return Promise.resolve({ isDirectory: () => true, isFile: () => true } as any);
}
return Promise.reject(new Error('Not found'));
});
// Call the method and expect it not to throw
await expect(validateRepositoryStructure('/mock/repo')).resolves.not.toThrow();
});
it('should pass when roles directory exists', async () => {
// Mock stat to return true for metadata.yml and roles
mockedFs.stat.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('metadata.yml') || pathStr.includes('roles')) {
return Promise.resolve({ isDirectory: () => true, isFile: () => true } as any);
}
return Promise.reject(new Error('Not found'));
});
// Call the method and expect it not to throw
await expect(validateRepositoryStructure('/mock/repo')).resolves.not.toThrow();
});
it('should pass when storage-systems directory exists', async () => {
// Mock stat to return true for metadata.yml and storage-systems
mockedFs.stat.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('metadata.yml') || pathStr.includes('storage-systems')) {
return Promise.resolve({ isDirectory: () => true, isFile: () => true } as any);
}
return Promise.reject(new Error('Not found'));
});
// Call the method and expect it not to throw
await expect(validateRepositoryStructure('/mock/repo')).resolves.not.toThrow();
});
it('should pass when items directory exists (backward compatibility)', async () => {
// Mock stat to return true for metadata.yml and items
mockedFs.stat.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('metadata.yml') || pathStr.includes('/items')) {
return Promise.resolve({ isDirectory: () => true, isFile: () => true } as any);
}
return Promise.reject(new Error('Not found'));
});
// Call the method and expect it not to throw
await expect(validateRepositoryStructure('/mock/repo')).resolves.not.toThrow();
});
});
describe('package-manager-template structure', () => {
it('should validate the package-manager-template structure', async () => {
// Mock stat to simulate the package-manager-template structure
mockedFs.stat.mockImplementation((path) => {
const pathStr = path.toString();
if (pathStr.includes('metadata.yml') ||
pathStr.includes('mcp-servers') ||
pathStr.includes('roles') ||
pathStr.includes('storage-systems')) {
return Promise.resolve({ isDirectory: () => true, isFile: () => true } as any);
}
if (pathStr.includes('items')) {
return Promise.reject(new Error('Directory not found'));
}
return Promise.resolve({ isDirectory: () => true } as any);
});
// Call the method and expect it not to throw
await expect(validateRepositoryStructure('/mock/repo')).resolves.not.toThrow();
});
});
});

View file

@ -0,0 +1,3 @@
export * from "./GitFetcher";
export * from "./PackageManagerManager";
export * from "./types";

View file

@ -0,0 +1,34 @@
/**
* Represents an individual package manager item
*/
export interface PackageManagerItem {
name: string;
description: string;
type: "role" | "mcp-server" | "storage" | "other";
url: string;
repoUrl: string;
author?: string;
tags?: string[];
version?: string;
lastUpdated?: string;
stars?: number;
downloads?: number;
}
/**
* Represents a Git repository source for package manager items
*/
export interface PackageManagerSource {
url: string;
name?: string;
enabled: boolean;
}
/**
* Represents a repository with its metadata and items
*/
export interface PackageManagerRepository {
metadata: any;
items: PackageManagerItem[];
url: string;
}

View file

@ -16,6 +16,7 @@ import {
import { McpServer } from "./mcp"
import { GitCommit } from "../utils/git"
import { Mode } from "./modes"
import { PackageManagerItem, PackageManagerSource } from "../services/package-manager/types"
export type { ApiConfigMeta, ToolProgressStatus }
@ -69,6 +70,7 @@ export interface ExtensionMessage {
| "maxReadFileLine"
| "fileSearchResults"
| "toggleApiConfigPin"
| "repositoryRefreshComplete"
text?: string
action?:
| "chatButtonClicked"
@ -76,6 +78,7 @@ export interface ExtensionMessage {
| "settingsButtonClicked"
| "historyButtonClicked"
| "promptsButtonClicked"
| "packageManagerButtonClicked"
| "didBecomeVisible"
invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage"
state?: ExtensionState
@ -111,6 +114,8 @@ export interface ExtensionMessage {
label?: string
}>
error?: string
items?: PackageManagerItem[]
url?: string // For repositoryRefreshComplete
}
export type ExtensionState = Pick<
@ -203,6 +208,8 @@ export type ExtensionState = Pick<
renderContext: "sidebar" | "editor"
settingsImportedAt?: number
packageManagerSources?: PackageManagerSource[]
packageManagerItems?: PackageManagerItem[]
}
export type { ClineMessage, ClineAsk, ClineSay }

View file

@ -1,6 +1,7 @@
import { z } from "zod"
import { ApiConfiguration, ApiProvider } from "./api"
import { Mode, PromptComponent, ModeConfig } from "./modes"
import { PackageManagerSource } from "../services/package-manager/types"
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
@ -121,6 +122,12 @@ export interface WebviewMessage {
| "maxReadFileLine"
| "searchFiles"
| "toggleApiConfigPin"
| "packageManagerSources"
| "fetchPackageManagerItems"
| "packageManagerButtonClicked"
| "refreshPackageManagerSource"
| "repositoryRefreshComplete"
| "openExternal"
text?: string
disabled?: boolean
askResponse?: ClineAskResponse
@ -146,6 +153,8 @@ export interface WebviewMessage {
source?: "global" | "project"
requestId?: string
ids?: string[]
sources?: PackageManagerSource[]
url?: string // For openExternal
}
export const checkoutDiffPayloadSchema = z.object({

View file

@ -0,0 +1,295 @@
import { jest } from "@jest/globals";
import { searchCommits, getCommitInfo, getWorkingState } from "../git";
// Mock child_process.exec
jest.mock("child_process", () => ({
exec: jest.fn(),
}));
// Mock util.promisify to return our own mock function
jest.mock("util", () => ({
promisify: jest.fn((fn) => {
return async (command, options) => {
// Call the original mock to maintain the mock implementation
return new Promise((resolve, reject) => {
fn(command, options || {}, (error, result) => {
if (error) {
reject(error);
}
else {
resolve(result);
}
});
});
};
}),
}));
// Mock extract-text
jest.mock("../../integrations/misc/extract-text", () => ({
truncateOutput: jest.fn((text) => text),
}));
describe("git utils", () => {
// Get the mock with proper typing
const { exec } = jest.requireMock("child_process");
const cwd = "/test/path";
beforeEach(() => {
jest.clearAllMocks();
});
describe("searchCommits", () => {
const mockCommitData = [
"abc123def456",
"abc123",
"fix: test commit",
"John Doe",
"2024-01-06",
"def456abc789",
"def456",
"feat: new feature",
"Jane Smith",
"2024-01-05",
].join("\n");
it("should return commits when git is installed and repo exists", async () => {
// Set up mock responses
const responses = new Map([
["git --version", { stdout: "git version 2.39.2", stderr: "" }],
["git rev-parse --git-dir", { stdout: ".git", stderr: "" }],
[
'git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short --grep="test" --regexp-ignore-case',
{ stdout: mockCommitData, stderr: "" },
],
]);
exec.mockImplementation((command, options, callback) => {
// Find matching response
for (const [cmd, response] of responses) {
if (command === cmd) {
callback(null, response);
return;
}
}
callback(new Error(`Unexpected command: ${command}`));
});
const result = await searchCommits("test", cwd);
// First verify the result is correct
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
hash: "abc123def456",
shortHash: "abc123",
subject: "fix: test commit",
author: "John Doe",
date: "2024-01-06",
});
// Then verify all commands were called correctly
expect(exec).toHaveBeenCalledWith("git --version", {}, expect.any(Function));
expect(exec).toHaveBeenCalledWith("git rev-parse --git-dir", { cwd }, expect.any(Function));
expect(exec).toHaveBeenCalledWith('git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short --grep="test" --regexp-ignore-case', { cwd }, expect.any(Function));
});
it("should return empty array when git is not installed", async () => {
exec.mockImplementation((command, options, callback) => {
if (command === "git --version") {
callback(new Error("git not found"));
return;
}
callback(new Error("Unexpected command"));
});
const result = await searchCommits("test", cwd);
expect(result).toEqual([]);
expect(exec).toHaveBeenCalledWith("git --version", {}, expect.any(Function));
});
it("should return empty array when not in a git repository", async () => {
const responses = new Map([
["git --version", { stdout: "git version 2.39.2", stderr: "" }],
["git rev-parse --git-dir", null], // null indicates error should be called
]);
exec.mockImplementation((command, options, callback) => {
const response = responses.get(command);
if (response === null) {
callback(new Error("not a git repository"));
}
else if (response) {
callback(null, response);
}
else {
callback(new Error("Unexpected command"));
}
});
const result = await searchCommits("test", cwd);
expect(result).toEqual([]);
expect(exec).toHaveBeenCalledWith("git --version", {}, expect.any(Function));
expect(exec).toHaveBeenCalledWith("git rev-parse --git-dir", { cwd }, expect.any(Function));
});
it("should handle hash search when grep search returns no results", async () => {
const responses = new Map([
["git --version", { stdout: "git version 2.39.2", stderr: "" }],
["git rev-parse --git-dir", { stdout: ".git", stderr: "" }],
[
'git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short --grep="abc123" --regexp-ignore-case',
{ stdout: "", stderr: "" },
],
[
'git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short --author-date-order abc123',
{ stdout: mockCommitData, stderr: "" },
],
]);
exec.mockImplementation((command, options, callback) => {
for (const [cmd, response] of responses) {
if (command === cmd) {
callback(null, response);
return;
}
}
callback(new Error("Unexpected command"));
});
const result = await searchCommits("abc123", cwd);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
hash: "abc123def456",
shortHash: "abc123",
subject: "fix: test commit",
author: "John Doe",
date: "2024-01-06",
});
});
});
describe("getCommitInfo", () => {
const mockCommitInfo = [
"abc123def456",
"abc123",
"fix: test commit",
"John Doe",
"2024-01-06",
"Detailed description",
].join("\n");
const mockStats = "1 file changed, 2 insertions(+), 1 deletion(-)";
const mockDiff = "@@ -1,1 +1,2 @@\n-old line\n+new line";
it("should return formatted commit info", async () => {
const responses = new Map([
["git --version", { stdout: "git version 2.39.2", stderr: "" }],
["git rev-parse --git-dir", { stdout: ".git", stderr: "" }],
[
'git show --format="%H%n%h%n%s%n%an%n%ad%n%b" --no-patch abc123',
{ stdout: mockCommitInfo, stderr: "" },
],
['git show --stat --format="" abc123', { stdout: mockStats, stderr: "" }],
['git show --format="" abc123', { stdout: mockDiff, stderr: "" }],
]);
exec.mockImplementation((command, options, callback) => {
for (const [cmd, response] of responses) {
if (command.startsWith(cmd)) {
callback(null, response);
return;
}
}
callback(new Error("Unexpected command"));
});
const result = await getCommitInfo("abc123", cwd);
expect(result).toContain("Commit: abc123");
expect(result).toContain("Author: John Doe");
expect(result).toContain("Files Changed:");
expect(result).toContain("Full Changes:");
});
it("should return error message when git is not installed", async () => {
exec.mockImplementation((command, options, callback) => {
if (command === "git --version") {
callback(new Error("git not found"));
return;
}
callback(new Error("Unexpected command"));
});
const result = await getCommitInfo("abc123", cwd);
expect(result).toBe("Git is not installed");
});
it("should return error message when not in a git repository", async () => {
const responses = new Map([
["git --version", { stdout: "git version 2.39.2", stderr: "" }],
["git rev-parse --git-dir", null], // null indicates error should be called
]);
exec.mockImplementation((command, options, callback) => {
const response = responses.get(command);
if (response === null) {
callback(new Error("not a git repository"));
}
else if (response) {
callback(null, response);
}
else {
callback(new Error("Unexpected command"));
}
});
const result = await getCommitInfo("abc123", cwd);
expect(result).toBe("Not a git repository");
});
});
describe("getWorkingState", () => {
const mockStatus = " M src/file1.ts\n?? src/file2.ts";
const mockDiff = "@@ -1,1 +1,2 @@\n-old line\n+new line";
it("should return working directory changes", async () => {
const responses = new Map([
["git --version", { stdout: "git version 2.39.2", stderr: "" }],
["git rev-parse --git-dir", { stdout: ".git", stderr: "" }],
["git status --short", { stdout: mockStatus, stderr: "" }],
["git diff HEAD", { stdout: mockDiff, stderr: "" }],
]);
exec.mockImplementation((command, options, callback) => {
for (const [cmd, response] of responses) {
if (command === cmd) {
callback(null, response);
return;
}
}
callback(new Error("Unexpected command"));
});
const result = await getWorkingState(cwd);
expect(result).toContain("Working directory changes:");
expect(result).toContain("src/file1.ts");
expect(result).toContain("src/file2.ts");
});
it("should return message when working directory is clean", async () => {
const responses = new Map([
["git --version", { stdout: "git version 2.39.2", stderr: "" }],
["git rev-parse --git-dir", { stdout: ".git", stderr: "" }],
["git status --short", { stdout: "", stderr: "" }],
]);
exec.mockImplementation((command, options, callback) => {
for (const [cmd, response] of responses) {
if (command === cmd) {
callback(null, response);
return;
}
}
callback(new Error("Unexpected command"));
});
const result = await getWorkingState(cwd);
expect(result).toBe("No changes in working directory");
});
it("should return error message when git is not installed", async () => {
exec.mockImplementation((command, options, callback) => {
if (command === "git --version") {
callback(new Error("git not found"));
return;
}
callback(new Error("Unexpected command"));
});
const result = await getWorkingState(cwd);
expect(result).toBe("Git is not installed");
});
it("should return error message when not in a git repository", async () => {
const responses = new Map([
["git --version", { stdout: "git version 2.39.2", stderr: "" }],
["git rev-parse --git-dir", null], // null indicates error should be called
]);
exec.mockImplementation((command, options, callback) => {
const response = responses.get(command);
if (response === null) {
callback(new Error("not a git repository"));
}
else if (response) {
callback(null, response);
}
else {
callback(new Error("Unexpected command"));
}
});
const result = await getWorkingState(cwd);
expect(result).toBe("Not a git repository");
});
});
});
//# sourceMappingURL=git.test.js.map

File diff suppressed because one or more lines are too long

129
src/utils/git.js Normal file
View file

@ -0,0 +1,129 @@
import { exec } from "child_process";
import { promisify } from "util";
import { truncateOutput } from "../integrations/misc/extract-text";
const execAsync = promisify(exec);
const GIT_OUTPUT_LINE_LIMIT = 500;
async function checkGitRepo(cwd) {
try {
await execAsync("git rev-parse --git-dir", { cwd });
return true;
}
catch (error) {
return false;
}
}
async function checkGitInstalled() {
try {
await execAsync("git --version");
return true;
}
catch (error) {
return false;
}
}
export async function searchCommits(query, cwd) {
try {
const isInstalled = await checkGitInstalled();
if (!isInstalled) {
console.error("Git is not installed");
return [];
}
const isRepo = await checkGitRepo(cwd);
if (!isRepo) {
console.error("Not a git repository");
return [];
}
// Search commits by hash or message, limiting to 10 results
const { stdout } = await execAsync(`git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short ` + `--grep="${query}" --regexp-ignore-case`, { cwd });
let output = stdout;
if (!output.trim() && /^[a-f0-9]+$/i.test(query)) {
// If no results from grep search and query looks like a hash, try searching by hash
const { stdout: hashStdout } = await execAsync(`git log -n 10 --format="%H%n%h%n%s%n%an%n%ad" --date=short ` + `--author-date-order ${query}`, { cwd }).catch(() => ({ stdout: "" }));
if (!hashStdout.trim()) {
return [];
}
output = hashStdout;
}
const commits = [];
const lines = output
.trim()
.split("\n")
.filter((line) => line !== "--");
for (let i = 0; i < lines.length; i += 5) {
commits.push({
hash: lines[i],
shortHash: lines[i + 1],
subject: lines[i + 2],
author: lines[i + 3],
date: lines[i + 4],
});
}
return commits;
}
catch (error) {
console.error("Error searching commits:", error);
return [];
}
}
export async function getCommitInfo(hash, cwd) {
try {
const isInstalled = await checkGitInstalled();
if (!isInstalled) {
return "Git is not installed";
}
const isRepo = await checkGitRepo(cwd);
if (!isRepo) {
return "Not a git repository";
}
// Get commit info, stats, and diff separately
const { stdout: info } = await execAsync(`git show --format="%H%n%h%n%s%n%an%n%ad%n%b" --no-patch ${hash}`, {
cwd,
});
const [fullHash, shortHash, subject, author, date, body] = info.trim().split("\n");
const { stdout: stats } = await execAsync(`git show --stat --format="" ${hash}`, { cwd });
const { stdout: diff } = await execAsync(`git show --format="" ${hash}`, { cwd });
const summary = [
`Commit: ${shortHash} (${fullHash})`,
`Author: ${author}`,
`Date: ${date}`,
`\nMessage: ${subject}`,
body ? `\nDescription:\n${body}` : "",
"\nFiles Changed:",
stats.trim(),
"\nFull Changes:",
].join("\n");
const output = summary + "\n\n" + diff.trim();
return truncateOutput(output, GIT_OUTPUT_LINE_LIMIT);
}
catch (error) {
console.error("Error getting commit info:", error);
return `Failed to get commit info: ${error instanceof Error ? error.message : String(error)}`;
}
}
export async function getWorkingState(cwd) {
try {
const isInstalled = await checkGitInstalled();
if (!isInstalled) {
return "Git is not installed";
}
const isRepo = await checkGitRepo(cwd);
if (!isRepo) {
return "Not a git repository";
}
// Get status of working directory
const { stdout: status } = await execAsync("git status --short", { cwd });
if (!status.trim()) {
return "No changes in working directory";
}
// Get all changes (both staged and unstaged) compared to HEAD
const { stdout: diff } = await execAsync("git diff HEAD", { cwd });
const lineLimit = GIT_OUTPUT_LINE_LIMIT;
const output = `Working directory changes:\n\n${status}\n\n${diff}`.trim();
return truncateOutput(output, lineLimit);
}
catch (error) {
console.error("Error getting working state:", error);
return `Failed to get working state: ${error instanceof Error ? error.message : String(error)}`;
}
}
//# sourceMappingURL=git.js.map

1
src/utils/git.js.map Normal file
View file

@ -0,0 +1 @@
{"version":3,"file":"git.js","sourceRoot":"","sources":["git.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,eAAe,CAAA;AACpC,OAAO,EAAE,SAAS,EAAE,MAAM,MAAM,CAAA;AAChC,OAAO,EAAE,cAAc,EAAE,MAAM,mCAAmC,CAAA;AAElE,MAAM,SAAS,GAAG,SAAS,CAAC,IAAI,CAAC,CAAA;AACjC,MAAM,qBAAqB,GAAG,GAAG,CAAA;AAUjC,KAAK,UAAU,YAAY,CAAC,GAAW;IACtC,IAAI,CAAC;QACJ,MAAM,SAAS,CAAC,yBAAyB,EAAE,EAAE,GAAG,EAAE,CAAC,CAAA;QACnD,OAAO,IAAI,CAAA;IACZ,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,OAAO,KAAK,CAAA;IACb,CAAC;AACF,CAAC;AAED,KAAK,UAAU,iBAAiB;IAC/B,IAAI,CAAC;QACJ,MAAM,SAAS,CAAC,eAAe,CAAC,CAAA;QAChC,OAAO,IAAI,CAAA;IACZ,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,OAAO,KAAK,CAAA;IACb,CAAC;AACF,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,KAAa,EAAE,GAAW;IAC7D,IAAI,CAAC;QACJ,MAAM,WAAW,GAAG,MAAM,iBAAiB,EAAE,CAAA;QAC7C,IAAI,CAAC,WAAW,EAAE,CAAC;YAClB,OAAO,CAAC,KAAK,CAAC,sBAAsB,CAAC,CAAA;YACrC,OAAO,EAAE,CAAA;QACV,CAAC;QAED,MAAM,MAAM,GAAG,MAAM,YAAY,CAAC,GAAG,CAAC,CAAA;QACtC,IAAI,CAAC,MAAM,EAAE,CAAC;YACb,OAAO,CAAC,KAAK,CAAC,sBAAsB,CAAC,CAAA;YACrC,OAAO,EAAE,CAAA;QACV,CAAC;QAED,4DAA4D;QAC5D,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,SAAS,CACjC,6DAA6D,GAAG,WAAW,KAAK,wBAAwB,EACxG,EAAE,GAAG,EAAE,CACP,CAAA;QAED,IAAI,MAAM,GAAG,MAAM,CAAA;QACnB,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,cAAc,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;YAClD,oFAAoF;YACpF,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,GAAG,MAAM,SAAS,CAC7C,6DAA6D,GAAG,uBAAuB,KAAK,EAAE,EAC9F,EAAE,GAAG,EAAE,CACP,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,CAAC,CAAA;YAE/B,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,EAAE,CAAC;gBACxB,OAAO,EAAE,CAAA;YACV,CAAC;YAED,MAAM,GAAG,UAAU,CAAA;QACpB,CAAC;QAED,MAAM,OAAO,GAAgB,EAAE,CAAA;QAC/B,MAAM,KAAK,GAAG,MAAM;aAClB,IAAI,EAAE;aACN,KAAK,CAAC,IAAI,CAAC;aACX,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,KAAK,IAAI,CAAC,CAAA;QAEjC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;YAC1C,OAAO,CAAC,IAAI,CAAC;gBACZ,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC;gBACd,SAAS,EAAE,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC;gBACvB,OAAO,EAAE,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC;gBACrB,MAAM,EAAE,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC;gBACpB,IAAI,EAAE,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC;aAClB,CAAC,CAAA;QACH,CAAC;QAED,OAAO,OAAO,CAAA;IACf,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,OAAO,CAAC,KAAK,CAAC,0BAA0B,EAAE,KAAK,CAAC,CAAA;QAChD,OAAO,EAAE,CAAA;IACV,CAAC;AACF,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,IAAY,EAAE,GAAW;IAC5D,IAAI,CAAC;QACJ,MAAM,WAAW,GAAG,MAAM,iBAAiB,EAAE,CAAA;QAC7C,IAAI,CAAC,WAAW,EAAE,CAAC;YAClB,OAAO,sBAAsB,CAAA;QAC9B,CAAC;QAED,MAAM,MAAM,GAAG,MAAM,YAAY,CAAC,GAAG,CAAC,CAAA;QACtC,IAAI,CAAC,MAAM,EAAE,CAAC;YACb,OAAO,sBAAsB,CAAA;QAC9B,CAAC;QAED,8CAA8C;QAC9C,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,SAAS,CAAC,2DAA2D,IAAI,EAAE,EAAE;YAC3G,GAAG;SACH,CAAC,CAAA;QACF,MAAM,CAAC,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;QAElF,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,SAAS,CAAC,+BAA+B,IAAI,EAAE,EAAE,EAAE,GAAG,EAAE,CAAC,CAAA;QAEzF,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,SAAS,CAAC,wBAAwB,IAAI,EAAE,EAAE,EAAE,GAAG,EAAE,CAAC,CAAA;QAEjF,MAAM,OAAO,GAAG;YACf,WAAW,SAAS,KAAK,QAAQ,GAAG;YACpC,WAAW,MAAM,EAAE;YACnB,SAAS,IAAI,EAAE;YACf,cAAc,OAAO,EAAE;YACvB,IAAI,CAAC,CAAC,CAAC,mBAAmB,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE;YACrC,kBAAkB;YAClB,KAAK,CAAC,IAAI,EAAE;YACZ,iBAAiB;SACjB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAEZ,MAAM,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,IAAI,CAAC,IAAI,EAAE,CAAA;QAC7C,OAAO,cAAc,CAAC,MAAM,EAAE,qBAAqB,CAAC,CAAA;IACrD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,OAAO,CAAC,KAAK,CAAC,4BAA4B,EAAE,KAAK,CAAC,CAAA;QAClD,OAAO,8BAA8B,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAA;IAC9F,CAAC;AACF,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,GAAW;IAChD,IAAI,CAAC;QACJ,MAAM,WAAW,GAAG,MAAM,iBAAiB,EAAE,CAAA;QAC7C,IAAI,CAAC,WAAW,EAAE,CAAC;YAClB,OAAO,sBAAsB,CAAA;QAC9B,CAAC;QAED,MAAM,MAAM,GAAG,MAAM,YAAY,CAAC,GAAG,CAAC,CAAA;QACtC,IAAI,CAAC,MAAM,EAAE,CAAC;YACb,OAAO,sBAAsB,CAAA;QAC9B,CAAC;QAED,kCAAkC;QAClC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,SAAS,CAAC,oBAAoB,EAAE,EAAE,GAAG,EAAE,CAAC,CAAA;QACzE,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC;YACpB,OAAO,iCAAiC,CAAA;QACzC,CAAC;QAED,8DAA8D;QAC9D,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,SAAS,CAAC,eAAe,EAAE,EAAE,GAAG,EAAE,CAAC,CAAA;QAClE,MAAM,SAAS,GAAG,qBAAqB,CAAA;QACvC,MAAM,MAAM,GAAG,iCAAiC,MAAM,OAAO,IAAI,EAAE,CAAC,IAAI,EAAE,CAAA;QAC1E,OAAO,cAAc,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;IACzC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,OAAO,CAAC,KAAK,CAAC,8BAA8B,EAAE,KAAK,CAAC,CAAA;QACpD,OAAO,gCAAgC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAA;IAChG,CAAC;AACF,CAAC"}

View file

@ -13,10 +13,13 @@ import HistoryView from "./components/history/HistoryView"
import SettingsView, { SettingsViewRef } from "./components/settings/SettingsView"
import WelcomeView from "./components/welcome/WelcomeView"
import McpView from "./components/mcp/McpView"
import PackageManagerView from "./components/package-manager/PackageManagerView"
import PromptsView from "./components/prompts/PromptsView"
import { HumanRelayDialog } from "./components/human-relay/HumanRelayDialog"
type Tab = "settings" | "history" | "mcp" | "prompts" | "chat"
type Tab = "settings" | "history" | "mcp" | "prompts" | "chat" | "packageManager"
const tabsByMessageAction: Partial<Record<NonNullable<ExtensionMessage["action"]>, Tab>> = {
chatButtonClicked: "chat",
@ -24,6 +27,7 @@ const tabsByMessageAction: Partial<Record<NonNullable<ExtensionMessage["action"]
promptsButtonClicked: "prompts",
mcpButtonClicked: "mcp",
historyButtonClicked: "history",
packageManagerButtonClicked: "packageManager",
}
const App = () => {
@ -105,6 +109,7 @@ const App = () => {
{tab === "mcp" && <McpView onDone={() => switchTab("chat")} />}
{tab === "history" && <HistoryView onDone={() => switchTab("chat")} />}
{tab === "settings" && <SettingsView ref={settingsRef} onDone={() => setTab("chat")} />}
{tab === "packageManager" && <PackageManagerView onDone={() => switchTab("chat")} />}
<ChatView
isHidden={tab !== "chat"}
showAnnouncement={showAnnouncement}

View file

@ -0,0 +1,21 @@
import React from "react";
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react";
/**
* A button that opens the package manager view when clicked
*/
const PackageManagerButton: React.FC = () => {
return (
<VSCodeButton
appearance="icon"
title="Package Manager"
onClick={() => {
window.postMessage({ type: "action", action: "packageManagerButtonClicked" }, "*");
}}
>
<span className="codicon codicon-extensions"></span>
</VSCodeButton>
);
};
export default PackageManagerButton;

View file

@ -0,0 +1,650 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react";
import { useExtensionState } from "../../context/ExtensionStateContext";
import { useAppTranslation } from "../../i18n/TranslationContext";
import { Tab, TabContent, TabHeader } from "../common/Tab";
import { vscode } from "@/utils/vscode";
import { PackageManagerItem, PackageManagerSource } from "../../../../src/services/package-manager/types";
type PackageManagerViewProps = {
onDone: () => void;
};
const PackageManagerView = ({ onDone }: PackageManagerViewProps) => {
const { packageManagerSources, setPackageManagerSources } = useExtensionState();
console.log("DEBUG: PackageManagerView initialized with sources:", packageManagerSources);
const { t } = useAppTranslation();
const [items, setItems] = useState<PackageManagerItem[]>([]);
const [activeTab, setActiveTab] = useState<"browse" | "sources">("browse");
const [refreshingUrls, setRefreshingUrls] = useState<string[]>([]);
// Track activeTab changes
useEffect(() => {
console.log("DEBUG: activeTab changed to", activeTab);
}, [activeTab]);
const [filters, setFilters] = useState({ type: "", search: "" });
const [sortBy, setSortBy] = useState("name");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
// Debug state changes
useEffect(() => {
console.log("DEBUG: items state changed", {
itemsLength: items.length,
isFetching
});
}, [items]);
// Track if we're currently fetching items to prevent duplicate requests
const [isFetching, setIsFetching] = useState(false);
// Use a ref to track if we've already fetched items
const hasInitialFetch = useRef(false);
// Fetch function without debounce for immediate execution
const fetchPackageManagerItems = useCallback(() => {
console.log("DEBUG: fetchPackageManagerItems called");
// Only send fetch request if we're not already fetching
if (!isFetching) {
setIsFetching(true);
try {
// Request items from extension with explicit fetch
vscode.postMessage({
type: "fetchPackageManagerItems",
forceRefresh: true // Add a flag to force refresh
} as any);
console.log("Explicitly fetching package manager items with force refresh...");
} catch (error) {
console.error("Failed to fetch package manager items:", error);
setIsFetching(false);
}
} else {
console.log("DEBUG: Skipping fetch because already in progress");
}
}, [isFetching]);
// Always fetch items when component mounts, regardless of other conditions
useEffect(() => {
console.log("DEBUG: PackageManagerView mount effect triggered");
// Force fetch on mount, ignoring all conditions
setTimeout(() => {
console.log("DEBUG: Forcing fetch on component mount");
setIsFetching(false); // Reset fetching state first
fetchPackageManagerItems();
hasInitialFetch.current = true;
}, 500); // Small delay to ensure component is fully mounted
}, []); // Empty dependency array means this runs once on mount
// Additional effect for when packageManagerSources changes
useEffect(() => {
console.log("DEBUG: PackageManagerView packageManagerSources effect triggered", {
hasInitialFetch: hasInitialFetch.current,
packageManagerSources,
isFetching,
itemsLength: items.length
});
// Only fetch if packageManagerSources changes and we're not already fetching
if (packageManagerSources && hasInitialFetch.current && !isFetching) {
console.log("DEBUG: Calling fetchPackageManagerItems due to sources change");
fetchPackageManagerItems();
}
}, [packageManagerSources, fetchPackageManagerItems, isFetching]);
// Handle message from extension
useEffect(() => {
console.log("DEBUG: Setting up message handler");
const handleMessage = (event: MessageEvent) => {
console.log("DEBUG: Message received in PackageManagerView", event.data);
console.log("DEBUG: Message type:", event.data.type);
console.log("DEBUG: Message state:", event.data.state ? "exists" : "undefined");
const message = event.data;
// Handle action messages - specifically for packageManagerButtonClicked
if (message.type === "action" && message.action === "packageManagerButtonClicked") {
console.log("DEBUG: Received packageManagerButtonClicked action, triggering fetch");
// Directly trigger a fetch when the package manager tab is clicked
setTimeout(() => {
vscode.postMessage({
type: "fetchPackageManagerItems",
forceRefresh: true
} as any);
}, 100);
}
// Handle repository refresh completion
if (message.type === "repositoryRefreshComplete" && message.url) {
console.log(`DEBUG: Repository refresh complete for ${message.url}`);
console.log(`DEBUG: Current refreshingUrls before update:`, refreshingUrls);
setRefreshingUrls(prev => {
const updated = prev.filter(url => url !== message.url);
console.log(`DEBUG: Updated refreshingUrls:`, updated);
return updated;
});
}
// Handle state messages with packageManagerItems
if (message.type === "state" && message.state) {
console.log("DEBUG: Received state message", message.state);
console.log("DEBUG: State has packageManagerItems:", message.state.packageManagerItems ? "yes" : "no");
if (message.state.packageManagerItems) {
console.log("DEBUG: packageManagerItems length:", message.state.packageManagerItems.length);
}
// Check for packageManagerItems
if (message.state.packageManagerItems) {
const receivedItems = message.state.packageManagerItems || [];
console.log("DEBUG: Received packageManagerItems", receivedItems.length);
console.log("DEBUG: Full message state:", message.state);
if (receivedItems.length > 0) {
console.log("DEBUG: First item:", receivedItems[0]);
console.log("DEBUG: All items:", JSON.stringify(receivedItems));
// Force a new array reference to ensure React detects the change
setItems([...receivedItems]);
setIsFetching(false);
console.log("DEBUG: States updated - items:", receivedItems.length, "isFetching: false");
} else {
console.log("DEBUG: Received empty items array");
setItems([]);
setIsFetching(false);
}
}
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);
// Filter items based on filters
console.log("DEBUG: Filtering items", { itemsCount: items.length, filters });
console.log("DEBUG: Items before filtering:", items.map(item => ({ name: item.name, type: item.type })));
const filteredItems = items.filter(item => {
// Filter by type
if (filters.type && item.type !== filters.type) {
return false;
}
// Filter by search term
if (filters.search) {
const searchTerm = filters.search.toLowerCase();
const nameMatch = item.name.toLowerCase().includes(searchTerm);
const descMatch = item.description.toLowerCase().includes(searchTerm);
const authorMatch = item.author?.toLowerCase().includes(searchTerm);
if (!nameMatch && !descMatch && !authorMatch) {
return false;
}
}
return true;
});
console.log("DEBUG: After filtering", { filteredItemsCount: filteredItems.length });
// Sort items
console.log("DEBUG: Sorting items", { filteredItemsCount: filteredItems.length, sortBy, sortOrder });
const sortedItems = [...filteredItems].sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case "name":
comparison = a.name.localeCompare(b.name);
break;
case "author":
comparison = (a.author || "").localeCompare(b.author || "");
break;
case "lastUpdated":
comparison = (a.lastUpdated || "").localeCompare(b.lastUpdated || "");
break;
case "stars":
comparison = (a.stars || 0) - (b.stars || 0);
break;
case "downloads":
comparison = (a.downloads || 0) - (b.downloads || 0);
break;
default:
comparison = a.name.localeCompare(b.name);
}
return sortOrder === "asc" ? comparison : -comparison;
});
console.log("DEBUG: Final sorted items", {
sortedItemsCount: sortedItems.length,
firstItem: sortedItems.length > 0 ? sortedItems[0].name : 'none'
});
// Add debug logging right before rendering
useEffect(() => {
console.log("DEBUG: Rendering with", {
sortedItemsCount: sortedItems.length,
firstItem: sortedItems.length > 0 ? `${sortedItems[0].name} (${sortedItems[0].type})` : 'none'
});
}, [sortedItems]);
// Log right before rendering
console.log("DEBUG: About to render with", {
itemsLength: items.length,
filteredItemsLength: filteredItems.length,
sortedItemsLength: sortedItems.length,
activeTab
});
return (
<Tab>
<TabHeader className="flex justify-between items-center">
<div className="flex items-center">
<h3 className="text-vscode-foreground m-0">Package Manager</h3>
</div>
<div className="flex gap-2">
<Button
variant={activeTab === "browse" ? "default" : "secondary"}
onClick={() => setActiveTab("browse")}
>
Browse
</Button>
<Button
variant={activeTab === "sources" ? "default" : "secondary"}
onClick={() => setActiveTab("sources")}
>
Sources
</Button>
<Button onClick={onDone}>Done</Button>
</div>
</TabHeader>
<TabContent>
{activeTab === "browse" ? (
<>
<div className="mb-4">
<input
type="text"
placeholder="Search package manager items..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="w-full p-2 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded"
/>
<div className="flex justify-between mt-2">
<div>
<label className="mr-2">Filter by type:</label>
<select
value={filters.type}
onChange={(e) => setFilters({ ...filters, type: e.target.value })}
className="p-1 bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border rounded"
>
<option value="">All types</option>
<option value="role">Role</option>
<option value="mcp-server">MCP Server</option>
<option value="storage">Storage</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label className="mr-2">Sort by:</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="p-1 bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border rounded mr-2"
>
<option value="name">Name</option>
<option value="author">Author</option>
<option value="lastUpdated">Last Updated</option>
<option value="stars">Stars</option>
<option value="downloads">Downloads</option>
</select>
<button
onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}
className="p-1 bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground rounded"
>
{sortOrder === "asc" ? "↑" : "↓"}
</button>
</div>
</div>
</div>
{console.log("DEBUG: Rendering condition", {
sortedItemsLength: sortedItems.length,
condition: sortedItems.length === 0 ? "empty" : "has items"
})}
{sortedItems.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-vscode-descriptionForeground">
<p>No package manager items found</p>
<Button
onClick={() => {
vscode.postMessage({
type: "fetchPackageManagerItems",
forceRefresh: true
} as any);
}}
className="mt-4"
>
<span className="codicon codicon-refresh mr-2"></span>
Refresh
</Button>
</div>
) : (
<div>
<div className="flex justify-between mb-4">
<p className="text-vscode-descriptionForeground">
{`${sortedItems.length} items found`}
</p>
<Button
onClick={() => {
vscode.postMessage({
type: "fetchPackageManagerItems",
forceRefresh: true
} as any);
}}
size="sm"
>
<span className="codicon codicon-refresh mr-2"></span>
Refresh
</Button>
</div>
<div className="grid grid-cols-1 gap-4">
{sortedItems.map((item) => (
<PackageManagerItemCard key={`${item.repoUrl}-${item.name}`} item={item} />
))}
</div>
</div>
)}
</>
) : (
<PackageManagerSourcesConfig
sources={packageManagerSources || []}
refreshingUrls={refreshingUrls}
setRefreshingUrls={setRefreshingUrls}
onSourcesChange={(sources) => {
setPackageManagerSources(sources);
vscode.postMessage({ type: "packageManagerSources", sources });
}}
/>
)}
</TabContent>
</Tab>
);
};
const PackageManagerItemCard = ({ item }: { item: PackageManagerItem }) => {
const { t } = useAppTranslation();
const getTypeLabel = (type: string) => {
switch (type) {
case "role":
return "Role";
case "mcp-server":
return "MCP Server";
case "storage":
return "Storage";
default:
return "Other";
}
};
const getTypeColor = (type: string) => {
switch (type) {
case "role":
return "bg-blue-600";
case "mcp-server":
return "bg-green-600";
case "storage":
return "bg-purple-600";
default:
return "bg-gray-600";
}
};
const handleOpenUrl = () => {
console.log(`PackageManagerItemCard: Opening URL: ${item.url}`);
vscode.postMessage({
type: "openExternal",
url: item.url
});
console.log(`PackageManagerItemCard: Sent openExternal message with URL: ${item.url}`);
};
return (
<div className="border border-vscode-panel-border rounded-md p-4 bg-vscode-panel-background">
<div className="flex justify-between items-start">
<div>
<h3 className="text-lg font-semibold text-vscode-foreground">{item.name}</h3>
{item.author && (
<p className="text-sm text-vscode-descriptionForeground">
{`by ${item.author}`}
</p>
)}
</div>
<span className={`px-2 py-1 text-xs text-white rounded-full ${getTypeColor(item.type)}`}>
{getTypeLabel(item.type)}
</span>
</div>
<p className="my-2 text-vscode-foreground">{item.description}</p>
{item.tags && item.tags.length > 0 && (
<div className="flex flex-wrap gap-1 my-2">
{item.tags.map(tag => (
<span
key={tag}
className="px-2 py-1 text-xs bg-vscode-badge-background text-vscode-badge-foreground rounded-full"
>
{tag}
</span>
))}
</div>
)}
<div className="flex justify-between items-center mt-4">
<div className="flex items-center gap-4 text-sm text-vscode-descriptionForeground">
{item.version && (
<span className="flex items-center">
<span className="codicon codicon-tag mr-1"></span>
{item.version}
</span>
)}
{item.lastUpdated && (
<span className="flex items-center">
<span className="codicon codicon-calendar mr-1"></span>
{item.lastUpdated}
</span>
)}
{item.stars !== undefined && (
<span className="flex items-center">
<span className="codicon codicon-star-full mr-1"></span>
{item.stars}
</span>
)}
{item.downloads !== undefined && (
<span className="flex items-center">
<span className="codicon codicon-cloud-download mr-1"></span>
{item.downloads}
</span>
)}
</div>
<Button onClick={handleOpenUrl}>
<span className="codicon codicon-link-external mr-2"></span>
View on GitHub
</Button>
</div>
</div>
);
};
const PackageManagerSourcesConfig = ({
sources,
refreshingUrls,
setRefreshingUrls,
onSourcesChange
}: {
sources: PackageManagerSource[];
refreshingUrls: string[];
setRefreshingUrls: React.Dispatch<React.SetStateAction<string[]>>;
onSourcesChange: (sources: PackageManagerSource[]) => void;
}) => {
const { t } = useAppTranslation();
const [newSourceUrl, setNewSourceUrl] = useState("");
const [newSourceName, setNewSourceName] = useState("");
const [error, setError] = useState("");
const handleAddSource = () => {
// Validate URL
if (!newSourceUrl) {
setError("URL cannot be empty");
return;
}
try {
new URL(newSourceUrl);
} catch (e) {
setError("Invalid URL format");
return;
}
// Check if URL already exists
if (sources.some(source => source.url === newSourceUrl)) {
setError("This URL is already in the list");
return;
}
// Check if maximum number of sources has been reached
const MAX_SOURCES = 10;
if (sources.length >= MAX_SOURCES) {
setError(`Maximum of ${MAX_SOURCES} sources allowed`);
return;
}
// Add new source
const newSource: PackageManagerSource = {
url: newSourceUrl,
name: newSourceName || undefined,
enabled: true
};
onSourcesChange([...sources, newSource]);
// Reset form
setNewSourceUrl("");
setNewSourceName("");
setError("");
};
const handleToggleSource = (index: number) => {
const updatedSources = [...sources];
updatedSources[index].enabled = !updatedSources[index].enabled;
onSourcesChange(updatedSources);
};
const handleRemoveSource = (index: number) => {
const updatedSources = sources.filter((_, i) => i !== index);
onSourcesChange(updatedSources);
};
const handleRefreshSource = (url: string) => {
// Add URL to refreshing list
setRefreshingUrls(prev => [...prev, url]);
// Send message to refresh this specific source
vscode.postMessage({
type: "refreshPackageManagerSource",
url
});
};
return (
<div>
<h4 className="text-vscode-foreground mb-2">Configure Package Manager Sources</h4>
<p className="text-vscode-descriptionForeground mb-4">
Add Git repositories that contain package manager items. These repositories will be fetched when browsing the package manager.
</p>
<div className="mb-6">
<h5 className="text-vscode-foreground mb-2">Add New Source</h5>
<div className="flex flex-col gap-2 mb-2">
<input
type="text"
placeholder="Git repository URL (e.g., https://github.com/username/repo)"
value={newSourceUrl}
onChange={(e) => {
setNewSourceUrl(e.target.value);
setError("");
}}
className="p-2 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded"
/>
<input
type="text"
placeholder="Display name (optional)"
value={newSourceName}
onChange={(e) => setNewSourceName(e.target.value)}
className="p-2 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded"
/>
</div>
{error && <p className="text-red-500 mb-2">{error}</p>}
<Button onClick={handleAddSource}>
<span className="codicon codicon-add mr-2"></span>
Add Source
</Button>
</div>
<h5 className="text-vscode-foreground mb-2">
Current Sources <span className="text-vscode-descriptionForeground text-sm">({sources.length}/10 max)</span>
</h5>
{sources.length === 0 ? (
<p className="text-vscode-descriptionForeground">
No sources configured. Add a source to get started.
</p>
) : (
<div className="grid grid-cols-1 gap-2">
{sources.map((source, index) => (
<div
key={source.url}
className="flex items-center justify-between p-3 border border-vscode-panel-border rounded-md bg-vscode-panel-background"
>
<div className="flex-1">
<div className="flex items-center">
<input
type="checkbox"
checked={source.enabled}
onChange={() => handleToggleSource(index)}
className="mr-2"
/>
<div>
<p className="text-vscode-foreground font-medium">{source.name || source.url}</p>
{source.name && <p className="text-xs text-vscode-descriptionForeground">{source.url}</p>}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleRefreshSource(source.url)}
title="Refresh this source"
className="text-vscode-foreground"
disabled={refreshingUrls.includes(source.url)}
>
<span className={`codicon ${refreshingUrls.includes(source.url) ? 'codicon-sync codicon-modifier-spin' : 'codicon-refresh'}`}></span>
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveSource(index)}
className="text-red-500"
>
<span className="codicon codicon-trash"></span>
</Button>
</div>
</div>
))}
</div>
)}
</div>
);
};
export default PackageManagerView;

View file

@ -11,6 +11,7 @@ import { Mode, CustomModePrompts, defaultModeSlug, defaultPrompts, ModeConfig }
import { CustomSupportPrompts } from "../../../src/shared/support-prompt"
import { experimentDefault, ExperimentId } from "../../../src/shared/experiments"
import { TelemetrySetting } from "../../../src/shared/TelemetrySetting"
import { PackageManagerSource } from "../../../src/services/package-manager/types"
export interface ExtensionStateContextType extends ExtensionState {
didHydrateState: boolean
@ -86,6 +87,8 @@ export interface ExtensionStateContextType extends ExtensionState {
pinnedApiConfigs?: Record<string, boolean>
setPinnedApiConfigs: (value: Record<string, boolean>) => void
togglePinnedApiConfig: (configName: string) => void
packageManagerSources?: PackageManagerSource[]
setPackageManagerSources: (value: PackageManagerSource[]) => void
}
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@ -160,6 +163,13 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
showRooIgnoredFiles: true, // Default to showing .rooignore'd files with lock symbol (current behavior).
renderContext: "sidebar",
maxReadFileLine: 500, // Default max read file line limit
packageManagerSources: [
{
url: "https://github.com/Smartsheet-JB-Brown/Package-Manager-Test",
name: "Official Roo-Code Package Manager",
enabled: true
}
],
pinnedApiConfigs: {}, // Empty object for pinned API configs
})
@ -182,8 +192,19 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
switch (message.type) {
case "state": {
const newState = message.state!
console.log("DEBUG: ExtensionStateContext received state message:", {
hasApiConfig: !!newState.apiConfiguration,
hasPackageManagerItems: !!newState.packageManagerItems,
packageManagerItemsCount: newState.packageManagerItems?.length || 0
});
setState((prevState) => mergeExtensionState(prevState, newState))
setShowWelcome(!checkExistKey(newState.apiConfiguration))
const shouldShowWelcome = !checkExistKey(newState.apiConfiguration);
console.log("DEBUG: Setting showWelcome to", shouldShowWelcome,
"based on apiConfiguration check:", newState.apiConfiguration ? "has config" : "missing config");
setShowWelcome(shouldShowWelcome)
setDidHydrateState(true)
break
}
@ -330,6 +351,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
return { ...prevState, pinnedApiConfigs: newPinned }
}),
setPackageManagerSources: (value) => setState((prevState) => ({ ...prevState, packageManagerSources: value })),
}
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>