From f479991a42b01eef61544c99844cfda58c02308b Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Mon, 30 Mar 2026 12:40:45 +0100 Subject: [PATCH 01/72] docs: add homescreen webview design spec Co-Authored-By: Claude Sonnet 4.6 --- .../2026-03-30-homescreen-webview-design.md | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-30-homescreen-webview-design.md diff --git a/docs/superpowers/specs/2026-03-30-homescreen-webview-design.md b/docs/superpowers/specs/2026-03-30-homescreen-webview-design.md new file mode 100644 index 0000000..e714fd7 --- /dev/null +++ b/docs/superpowers/specs/2026-03-30-homescreen-webview-design.md @@ -0,0 +1,80 @@ +# Homescreen WebviewView — Design Spec + +**Date:** 2026-03-30 +**Status:** Approved + +## Overview + +Add a minimal dashboard "homescreen" as a `WebviewView` in the Cloudinary sidebar. It occupies the same space as the tree view and is replaced by it when the user clicks "Browse Library". This is the foundation for future features: guided MCP server installation, a chat panel, and eventual retirement of the existing welcome screen panel. + +## Architecture + +Two views are registered in the `cloudinary` activity bar container in `package.json`. A VS Code context variable `cloudinary.activeView` (value: `'homescreen'` or `'library'`) controls which one is visible via `when` clauses: + +```json +"views": { + "cloudinary": [ + { + "id": "cloudinaryHomescreen", + "name": "Cloudinary", + "type": "webview", + "when": "cloudinary.activeView == 'homescreen'" + }, + { + "id": "cloudinaryMediaLibrary", + "name": "", + "when": "cloudinary.activeView == 'library'" + } + ] +} +``` + +On activation, `extension.ts` sets `cloudinary.activeView` to `'homescreen'`. No persistent state — every VS Code session starts on the homescreen. + +The existing first-run welcome panel logic (`cloudinary.firstRun` globalState) is left unchanged. + +## Homescreen Content + +`HomescreenViewProvider` in `src/webview/homescreenView.ts` implements `vscode.WebviewViewProvider`. It receives the `CloudinaryTreeDataProvider` to read connection state. + +Layout (top to bottom in the narrow sidebar): + +1. **Connection status card** — shows cloud name when configured, or an "unconfigured" warning with a "Configure" button that fires `cloudinary.openGlobalConfig` +2. **Quick action buttons** (stacked vertically): + - "Browse Library" → fires `cloudinary.showLibrary` + - "Upload" → fires `cloudinary.openUploadWidget` + - "Search" → fires `cloudinary.searchAssets` +3. **Footer** — "Welcome Guide" link that fires `cloudinary.openWelcomeScreen` (temporary, until the welcome screen is retired) + +Styling uses the existing design system (`createWebviewDocument`, VS Code CSS variables via `--vscode-*`, component library from `src/webview/`). + +## Navigation & State + +Two new commands: + +| Command | Action | +|---|---| +| `cloudinary.showHomescreen` | Sets context `cloudinary.activeView` = `'homescreen'` | +| `cloudinary.showLibrary` | Sets context `cloudinary.activeView` = `'library'`, then focuses the sidebar | + +The tree view title bar gets a `$(home)` icon button wired to `cloudinary.showHomescreen` at `navigation@0`, allowing users to return to the homescreen from the library. + +## Files + +**New:** +- `src/webview/homescreenView.ts` — `HomescreenViewProvider` (WebviewViewProvider) +- `src/webview/client/homescreen.ts` — client-side message handlers +- `src/webview/scripts/homescreen.ts` — exports `getHomescreenScript()` + +**Modified:** +- `package.json` — homescreen view, two new commands, `$(home)` tree title menu entry +- `src/extension.ts` — set initial context, register `HomescreenViewProvider` +- `src/commands/registerCommands.ts` — register `showHomescreen` and `showLibrary` +- `src/webview/scripts/index.ts` — export `getHomescreenScript` +- `esbuild.js` — add `homescreen.ts` as a client script entry point + +## Future Considerations + +- The welcome screen panel (`cloudinaryWelcome`) will be retired once the homescreen grows to cover its content (MCP setup guide, resources, etc.) +- The homescreen WebviewView is the intended host for guided MCP server installation and a future chat panel +- The `cloudinary.activeView` context variable can be extended to additional named views as the hub grows From f4ac489be350d7629f1c90f7fb8c163cec293c1d Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Mon, 30 Mar 2026 13:34:29 +0100 Subject: [PATCH 02/72] docs: add homescreen webview implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-03-30-homescreen-webview.md | 493 ++++++++++++++++++ 1 file changed, 493 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-30-homescreen-webview.md diff --git a/docs/superpowers/plans/2026-03-30-homescreen-webview.md b/docs/superpowers/plans/2026-03-30-homescreen-webview.md new file mode 100644 index 0000000..e124e43 --- /dev/null +++ b/docs/superpowers/plans/2026-03-30-homescreen-webview.md @@ -0,0 +1,493 @@ +# Homescreen WebviewView Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a minimal dashboard homescreen as a `WebviewViewProvider` in the Cloudinary sidebar, toggling with the existing tree view via VS Code context variables. + +**Architecture:** Two views (`cloudinaryHomescreen` as a WebviewView and `cloudinaryMediaLibrary` as a tree view) are registered in the `cloudinary` activity bar container with mutually exclusive `when` clauses driven by the `cloudinary.activeView` context variable. The homescreen shows connection status and three quick-action buttons; `cloudinary.showLibrary` and `cloudinary.showHomescreen` commands switch between them. + +**Tech Stack:** TypeScript, VS Code Extension API (`WebviewViewProvider`, `setContext`), esbuild, existing design system (`createWebviewDocument`, VS Code CSS variables) + +--- + +## File Map + +| Action | File | Responsibility | +|--------|------|----------------| +| Create | `src/webview/client/homescreen.ts` | Client-side message handlers bundled to `media/scripts/homescreen.js` | +| Create | `src/webview/homescreenView.ts` | `HomescreenViewProvider` — generates HTML, handles messages | +| Modify | `esbuild.js:24-27` | Add `homescreen.ts` to browser entry points | +| Modify | `src/commands/registerCommands.ts:22-44` | Register `showHomescreen` and `showLibrary` commands | +| Modify | `package.json:50-57` (views) | Add homescreen view with `when` clause, add `when` to tree view | +| Modify | `package.json:58-116` (commands) | Add two new commands | +| Modify | `package.json:117-144` (menus) | Add `$(home)` button to tree view title bar | +| Modify | `src/extension.ts:39-44` | Set initial context, register `HomescreenViewProvider` | + +--- + +### Task 1: Add homescreen client script entry to esbuild + +**Files:** +- Modify: `esbuild.js:24-27` + +- [ ] **Step 1: Add the entry point** + +In `esbuild.js`, find the `entryPoints` array for the webview build (around line 24) and add the homescreen client script: + +```js +// Before: +entryPoints: [ + "src/webview/client/preview.ts", + "src/webview/client/upload-widget.ts", + "src/webview/client/welcome.ts", +], + +// After: +entryPoints: [ + "src/webview/client/preview.ts", + "src/webview/client/upload-widget.ts", + "src/webview/client/welcome.ts", + "src/webview/client/homescreen.ts", +], +``` + +- [ ] **Step 2: Commit** + +```bash +git add esbuild.js +git commit -m "build: add homescreen client script entry to esbuild" +``` + +--- + +### Task 2: Create homescreen client script + +**Files:** +- Create: `src/webview/client/homescreen.ts` + +- [ ] **Step 1: Create the file** + +```typescript +/** + * Homescreen webview client-side script. + * Handles button actions by posting messages to the extension host. + */ + +import { initVSCode, getVSCode } from "./common"; + +function openGlobalConfig(): void { + getVSCode()?.postMessage({ command: "openGlobalConfig" }); +} + +function showLibrary(): void { + getVSCode()?.postMessage({ command: "showLibrary" }); +} + +function openUploadWidget(): void { + getVSCode()?.postMessage({ command: "openUploadWidget" }); +} + +function searchAssets(): void { + getVSCode()?.postMessage({ command: "searchAssets" }); +} + +function openWelcomeScreen(): void { + getVSCode()?.postMessage({ command: "openWelcomeScreen" }); +} + +declare global { + interface Window { + openGlobalConfig: typeof openGlobalConfig; + showLibrary: typeof showLibrary; + openUploadWidget: typeof openUploadWidget; + searchAssets: typeof searchAssets; + openWelcomeScreen: typeof openWelcomeScreen; + } +} + +window.openGlobalConfig = openGlobalConfig; +window.showLibrary = showLibrary; +window.openUploadWidget = openUploadWidget; +window.searchAssets = searchAssets; +window.openWelcomeScreen = openWelcomeScreen; + +initVSCode(); +``` + +- [ ] **Step 2: Verify it type-checks** + +```bash +npm run check-types +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/webview/client/homescreen.ts +git commit -m "feat: add homescreen client script" +``` + +--- + +### Task 3: Create `HomescreenViewProvider` + +**Files:** +- Create: `src/webview/homescreenView.ts` + +This provider implements `vscode.WebviewViewProvider`. VS Code calls `resolveWebviewView` lazily when the sidebar becomes visible. By that time `activate()` has completed and the `CloudinaryTreeDataProvider` has its credentials set. + +- [ ] **Step 1: Create the file** + +```typescript +/** + * Homescreen WebviewView provider. + * Renders the minimal dashboard in the Cloudinary sidebar. + */ + +import * as vscode from "vscode"; +import { CloudinaryTreeDataProvider } from "../tree/treeDataProvider"; +import { createWebviewDocument, getScriptUri } from "./webviewUtils"; +import { escapeHtml } from "./utils/helpers"; + +export class HomescreenViewProvider implements vscode.WebviewViewProvider { + public static readonly viewType = "cloudinaryHomescreen"; + + constructor( + private readonly _extensionUri: vscode.Uri, + private readonly _provider: CloudinaryTreeDataProvider + ) {} + + resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken + ): void { + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [vscode.Uri.joinPath(this._extensionUri, "media")], + }; + + const scriptUri = getScriptUri( + webviewView.webview, + this._extensionUri, + "homescreen.js" + ); + + webviewView.webview.html = createWebviewDocument({ + title: "Cloudinary", + webview: webviewView.webview, + extensionUri: this._extensionUri, + bodyContent: this._getBodyContent(), + additionalScripts: [scriptUri], + }); + + webviewView.webview.onDidReceiveMessage( + (message: { command: string }) => { + switch (message.command) { + case "openGlobalConfig": + vscode.commands.executeCommand("cloudinary.openGlobalConfig"); + break; + case "showLibrary": + vscode.commands.executeCommand("cloudinary.showLibrary"); + break; + case "openUploadWidget": + vscode.commands.executeCommand("cloudinary.openUploadWidget"); + break; + case "searchAssets": + vscode.commands.executeCommand("cloudinary.searchAssets"); + break; + case "openWelcomeScreen": + vscode.commands.executeCommand("cloudinary.openWelcomeScreen"); + break; + } + } + ); + } + + private _getBodyContent(): string { + const hasConfig = !!(this._provider.cloudName && this._provider.apiKey); + const cloudName = escapeHtml(this._provider.cloudName || ""); + + return ` +
+
+
+ ${hasConfig ? "✓" : "⚠"} +
+
+
${hasConfig ? cloudName : "Not Configured"}
+

${hasConfig ? "Connected" : "Setup required"}

+
+ ${!hasConfig + ? `` + : ""} +
+ +
+ + + +
+ +
+ Welcome Guide +
+
+ `; + } +} +``` + +- [ ] **Step 2: Verify it type-checks** + +```bash +npm run check-types +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/webview/homescreenView.ts +git commit -m "feat: add HomescreenViewProvider" +``` + +--- + +### Task 4: Register navigation commands + +**Files:** +- Modify: `src/commands/registerCommands.ts` + +- [ ] **Step 1: Add the two commands to `registerAllCommands`** + +In `registerCommands.ts`, add at the top of `registerAllCommands` (before `registerSearch`): + +```typescript +// Add this import at the top of the file (no new import needed — vscode is already available via parameters) + +// Inside registerAllCommands, before registerSearch(context, provider): +context.subscriptions.push( + vscode.commands.registerCommand("cloudinary.showHomescreen", () => { + vscode.commands.executeCommand("setContext", "cloudinary.activeView", "homescreen"); + }) +); + +context.subscriptions.push( + vscode.commands.registerCommand("cloudinary.showLibrary", () => { + vscode.commands.executeCommand("setContext", "cloudinary.activeView", "library"); + vscode.commands.executeCommand("workbench.view.extension.cloudinary"); + }) +); +``` + +The full updated `registerAllCommands` function: + +```typescript +function registerAllCommands( + context: vscode.ExtensionContext, + provider: CloudinaryTreeDataProvider, + statusBar: vscode.StatusBarItem +) { + context.subscriptions.push( + vscode.commands.registerCommand("cloudinary.showHomescreen", () => { + vscode.commands.executeCommand("setContext", "cloudinary.activeView", "homescreen"); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("cloudinary.showLibrary", () => { + vscode.commands.executeCommand("setContext", "cloudinary.activeView", "library"); + vscode.commands.executeCommand("workbench.view.extension.cloudinary"); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("cloudinary.refresh", () => + provider.refresh({ + folderPath: '', + nextCursor: null, + searchQuery: null, + resourceTypeFilter: 'all' + }) + ) + ); + + registerSearch(context, provider); + registerClearSearch(context, provider); + registerViewOptions(context, provider); + registerPreview(context); + registerUpload(context, provider); + registerClipboard(context); + registerSwitchEnv(context, provider, statusBar); + registerWelcomeScreen(context, provider); +} +``` + +- [ ] **Step 2: Verify it type-checks** + +```bash +npm run check-types +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/commands/registerCommands.ts +git commit -m "feat: register showHomescreen and showLibrary commands" +``` + +--- + +### Task 5: Update `package.json` — views, commands, menus + +**Files:** +- Modify: `package.json` + +Make three separate edits in `package.json`: + +- [ ] **Step 1: Update the `views` section** + +Replace the existing `"views"` block: + +```json +// Before: +"views": { + "cloudinary": [ + { + "id": "cloudinaryMediaLibrary", + "name": "" + } + ] +} + +// After: +"views": { + "cloudinary": [ + { + "id": "cloudinaryHomescreen", + "name": "Cloudinary", + "type": "webview", + "when": "cloudinary.activeView == 'homescreen'" + }, + { + "id": "cloudinaryMediaLibrary", + "name": "", + "when": "cloudinary.activeView == 'library'" + } + ] +} +``` + +- [ ] **Step 2: Add two new commands to the `commands` array** + +Add after the `cloudinary.openWelcomeScreen` entry (before the closing `]` of the commands array): + +```json +{ + "command": "cloudinary.showHomescreen", + "title": "Go to Home", + "icon": "$(home)", + "category": "Cloudinary" +}, +{ + "command": "cloudinary.showLibrary", + "title": "Browse Media Library", + "category": "Cloudinary" +} +``` + +- [ ] **Step 3: Add `$(home)` button to tree view title bar** + +Add a new menu entry at the start of the `"view/title"` array (before the `cloudinary.refresh` entry): + +```json +{ + "command": "cloudinary.showHomescreen", + "when": "view == cloudinaryMediaLibrary", + "group": "navigation@0" +} +``` + +- [ ] **Step 4: Verify JSON is valid and build succeeds** + +```bash +npm run compile +``` + +Expected: build completes with no errors, `dist/extension.js` and `media/scripts/homescreen.js` are produced. + +- [ ] **Step 5: Commit** + +```bash +git add package.json +git commit -m "feat: register homescreen view and navigation commands in package.json" +``` + +--- + +### Task 6: Wire up provider in `extension.ts` + +**Files:** +- Modify: `src/extension.ts` + +- [ ] **Step 1: Add import at the top of `extension.ts`** + +After the existing imports, add: + +```typescript +import { HomescreenViewProvider } from "./webview/homescreenView"; +``` + +- [ ] **Step 2: Set context and register provider in `activate()`** + +Add immediately after `const cloudinaryProvider = new CloudinaryTreeDataProvider();` (line 40), before `const isFirstRun = ...`: + +```typescript +// Set initial view to homescreen +vscode.commands.executeCommand("setContext", "cloudinary.activeView", "homescreen"); + +// Register homescreen sidebar view +const homescreenProvider = new HomescreenViewProvider(context.extensionUri, cloudinaryProvider); +context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + HomescreenViewProvider.viewType, + homescreenProvider, + { webviewOptions: { retainContextWhenHidden: true } } + ) +); +``` + +- [ ] **Step 3: Verify type-check and full build** + +```bash +npm run compile +``` + +Expected: no errors. `dist/extension.js` and `media/scripts/homescreen.js` are updated. + +- [ ] **Step 4: Manual smoke test in VS Code** + +Press `F5` in VS Code to launch the Extension Development Host. Verify: +1. The Cloudinary sidebar shows the homescreen dashboard (status card + three buttons + Welcome Guide link) +2. Clicking "Browse Library" switches to the tree view +3. The `$(home)` icon in the tree view title bar switches back to the homescreen +4. If credentials are unconfigured, the status card shows "Not Configured" with a "Configure" button + +- [ ] **Step 5: Commit** + +```bash +git add src/extension.ts +git commit -m "feat: wire up HomescreenViewProvider in extension activation" +``` From 49788725ad7d752c9a4173c2ce332f2a4df474ee Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Mon, 30 Mar 2026 13:48:12 +0100 Subject: [PATCH 03/72] build: add homescreen client script entry to esbuild --- esbuild.js | 1 + 1 file changed, 1 insertion(+) diff --git a/esbuild.js b/esbuild.js index c207238..c32bb96 100644 --- a/esbuild.js +++ b/esbuild.js @@ -25,6 +25,7 @@ async function main() { "src/webview/client/preview.ts", "src/webview/client/upload-widget.ts", "src/webview/client/welcome.ts", + "src/webview/client/homescreen.ts", ], bundle: true, format: "iife", From cb1e1c4eb58d2ee34e33cda140673a4cad2e3c31 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Mon, 30 Mar 2026 13:50:05 +0100 Subject: [PATCH 04/72] feat: add homescreen client script --- src/webview/client/homescreen.ts | 44 ++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/webview/client/homescreen.ts diff --git a/src/webview/client/homescreen.ts b/src/webview/client/homescreen.ts new file mode 100644 index 0000000..bc10825 --- /dev/null +++ b/src/webview/client/homescreen.ts @@ -0,0 +1,44 @@ +/** + * Homescreen webview client-side script. + * Handles button actions by posting messages to the extension host. + */ + +import { initVSCode, getVSCode } from "./common"; + +function openGlobalConfig(): void { + getVSCode()?.postMessage({ command: "openGlobalConfig" }); +} + +function showLibrary(): void { + getVSCode()?.postMessage({ command: "showLibrary" }); +} + +function openUploadWidget(): void { + getVSCode()?.postMessage({ command: "openUploadWidget" }); +} + +function searchAssets(): void { + getVSCode()?.postMessage({ command: "searchAssets" }); +} + +function openWelcomeScreen(): void { + getVSCode()?.postMessage({ command: "openWelcomeScreen" }); +} + +declare global { + interface Window { + openGlobalConfig: typeof openGlobalConfig; + showLibrary: typeof showLibrary; + openUploadWidget: typeof openUploadWidget; + searchAssets: typeof searchAssets; + openWelcomeScreen: typeof openWelcomeScreen; + } +} + +window.openGlobalConfig = openGlobalConfig; +window.showLibrary = showLibrary; +window.openUploadWidget = openUploadWidget; +window.searchAssets = searchAssets; +window.openWelcomeScreen = openWelcomeScreen; + +initVSCode(); From 93e3d8f844ee39c8a9e87294afb43f3453931ca4 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Mon, 30 Mar 2026 13:59:13 +0100 Subject: [PATCH 05/72] feat: add HomescreenViewProvider --- src/webview/homescreenView.ts | 103 ++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src/webview/homescreenView.ts diff --git a/src/webview/homescreenView.ts b/src/webview/homescreenView.ts new file mode 100644 index 0000000..0b319f1 --- /dev/null +++ b/src/webview/homescreenView.ts @@ -0,0 +1,103 @@ +/** + * Homescreen WebviewView provider. + * Renders the minimal dashboard in the Cloudinary sidebar. + */ + +import * as vscode from "vscode"; +import { CloudinaryTreeDataProvider } from "../tree/treeDataProvider"; +import { createWebviewDocument, getScriptUri } from "./webviewUtils"; +import { escapeHtml } from "./utils/helpers"; + +export class HomescreenViewProvider implements vscode.WebviewViewProvider { + public static readonly viewType = "cloudinaryHomescreen"; + + constructor( + private readonly _extensionUri: vscode.Uri, + private readonly _provider: CloudinaryTreeDataProvider + ) {} + + resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken + ): void { + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [vscode.Uri.joinPath(this._extensionUri, "media")], + }; + + const scriptUri = getScriptUri( + webviewView.webview, + this._extensionUri, + "homescreen.js" + ); + + webviewView.webview.html = createWebviewDocument({ + title: "Cloudinary", + webview: webviewView.webview, + extensionUri: this._extensionUri, + bodyContent: this._getBodyContent(), + additionalScripts: [scriptUri], + }); + + webviewView.webview.onDidReceiveMessage( + (message: { command: string }) => { + switch (message.command) { + case "openGlobalConfig": + vscode.commands.executeCommand("cloudinary.openGlobalConfig"); + break; + case "showLibrary": + vscode.commands.executeCommand("cloudinary.showLibrary"); + break; + case "openUploadWidget": + vscode.commands.executeCommand("cloudinary.openUploadWidget"); + break; + case "searchAssets": + vscode.commands.executeCommand("cloudinary.searchAssets"); + break; + case "openWelcomeScreen": + vscode.commands.executeCommand("cloudinary.openWelcomeScreen"); + break; + } + } + ); + } + + private _getBodyContent(): string { + const hasConfig = !!(this._provider.cloudName && this._provider.apiKey); + const cloudName = escapeHtml(this._provider.cloudName || ""); + + return ` +
+
+
+ ${hasConfig ? "✓" : "⚠"} +
+
+
${hasConfig ? cloudName : "Not Configured"}
+

${hasConfig ? "Connected" : "Setup required"}

+
+ ${!hasConfig + ? `` + : ""} +
+ +
+ + + +
+ +
+ Welcome Guide +
+
+ `; + } +} From bed8fd496eac6f2965feede448512cd1233e309b Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Mon, 30 Mar 2026 14:03:20 +0100 Subject: [PATCH 06/72] feat: register showHomescreen and showLibrary commands --- src/commands/registerCommands.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts index 9a50e14..3b11f54 100644 --- a/src/commands/registerCommands.ts +++ b/src/commands/registerCommands.ts @@ -20,6 +20,19 @@ function registerAllCommands( provider: CloudinaryTreeDataProvider, statusBar: vscode.StatusBarItem ) { + context.subscriptions.push( + vscode.commands.registerCommand("cloudinary.showHomescreen", () => { + vscode.commands.executeCommand("setContext", "cloudinary.activeView", "homescreen"); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("cloudinary.showLibrary", () => { + vscode.commands.executeCommand("setContext", "cloudinary.activeView", "library"); + vscode.commands.executeCommand("workbench.view.extension.cloudinary"); + }) + ); + context.subscriptions.push( vscode.commands.registerCommand("cloudinary.refresh", () => provider.refresh({ From f7dfc2dcf5ae47f4bb662d153434353662abc1d4 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Mon, 30 Mar 2026 14:06:34 +0100 Subject: [PATCH 07/72] feat: register homescreen view and navigation commands in package.json --- package.json | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 55de9be..8a18808 100644 --- a/package.json +++ b/package.json @@ -49,9 +49,16 @@ }, "views": { "cloudinary": [ + { + "id": "cloudinaryHomescreen", + "name": "Cloudinary", + "type": "webview", + "when": "cloudinary.activeView == 'homescreen'" + }, { "id": "cloudinaryMediaLibrary", - "name": "" + "name": "", + "when": "cloudinary.activeView == 'library'" } ] }, @@ -112,10 +119,26 @@ "command": "cloudinary.openWelcomeScreen", "title": "Open Welcome Guide", "category": "Cloudinary" + }, + { + "command": "cloudinary.showHomescreen", + "title": "Go to Home", + "icon": "$(home)", + "category": "Cloudinary" + }, + { + "command": "cloudinary.showLibrary", + "title": "Browse Media Library", + "category": "Cloudinary" } ], "menus": { "view/title": [ + { + "command": "cloudinary.showHomescreen", + "when": "view == cloudinaryMediaLibrary", + "group": "navigation@0" + }, { "command": "cloudinary.refresh", "when": "view == cloudinaryMediaLibrary", From 4e5e37fd47bfcc7ff6dbfea583b1d776445941ff Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Mon, 30 Mar 2026 14:08:41 +0100 Subject: [PATCH 08/72] feat: wire up HomescreenViewProvider in extension activation --- src/extension.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/extension.ts b/src/extension.ts index 9f52cab..4bab007 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,6 +11,7 @@ import { registerAllCommands } from "./commands/registerCommands"; import { CloudinaryTreeDataProvider } from "./tree/treeDataProvider"; import { v2 as cloudinary } from "cloudinary"; import { generateUserAgent } from "./utils/userAgent"; +import { HomescreenViewProvider } from "./webview/homescreenView"; let statusBar: vscode.StatusBarItem; @@ -39,6 +40,19 @@ function getStatusBarTooltip(dynamicFolders: boolean): string { export async function activate(context: vscode.ExtensionContext) { const cloudinaryProvider = new CloudinaryTreeDataProvider(); + // Set initial view to homescreen + vscode.commands.executeCommand("setContext", "cloudinary.activeView", "homescreen"); + + // Register homescreen sidebar view + const homescreenProvider = new HomescreenViewProvider(context.extensionUri, cloudinaryProvider); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + HomescreenViewProvider.viewType, + homescreenProvider, + { webviewOptions: { retainContextWhenHidden: true } } + ) + ); + // Check if this is the first run of the extension const isFirstRun = context.globalState.get('cloudinary.firstRun', true); From 8002ff338fcb87c7fe7a135f26d15114a029378d Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Mon, 30 Mar 2026 14:14:16 +0100 Subject: [PATCH 09/72] fix: use initCommon() in homescreen client script for pattern consistency --- src/webview/client/homescreen.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/webview/client/homescreen.ts b/src/webview/client/homescreen.ts index bc10825..a8e184e 100644 --- a/src/webview/client/homescreen.ts +++ b/src/webview/client/homescreen.ts @@ -3,7 +3,7 @@ * Handles button actions by posting messages to the extension host. */ -import { initVSCode, getVSCode } from "./common"; +import { initCommon, getVSCode } from "./common"; function openGlobalConfig(): void { getVSCode()?.postMessage({ command: "openGlobalConfig" }); @@ -41,4 +41,4 @@ window.openUploadWidget = openUploadWidget; window.searchAssets = searchAssets; window.openWelcomeScreen = openWelcomeScreen; -initVSCode(); +initCommon(); From 782f8c6ded75513d82a32fe07db68d9be374e39f Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Mon, 30 Mar 2026 14:22:12 +0100 Subject: [PATCH 10/72] fix: show homescreen by default before extension activates --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8a18808..b655f79 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "id": "cloudinaryHomescreen", "name": "Cloudinary", "type": "webview", - "when": "cloudinary.activeView == 'homescreen'" + "when": "cloudinary.activeView != 'library'" }, { "id": "cloudinaryMediaLibrary", From accf5f124287cd93a2e74606ed247313e55eed81 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Mon, 30 Mar 2026 14:26:02 +0100 Subject: [PATCH 11/72] fix: use addEventListener instead of onclick to comply with CSP --- src/webview/client/homescreen.ts | 44 ++++++++------------------------ src/webview/homescreenView.ts | 10 ++++---- 2 files changed, 15 insertions(+), 39 deletions(-) diff --git a/src/webview/client/homescreen.ts b/src/webview/client/homescreen.ts index a8e184e..4b12618 100644 --- a/src/webview/client/homescreen.ts +++ b/src/webview/client/homescreen.ts @@ -1,44 +1,20 @@ /** * Homescreen webview client-side script. - * Handles button actions by posting messages to the extension host. + * Wires up button event listeners and posts messages to the extension host. */ import { initCommon, getVSCode } from "./common"; -function openGlobalConfig(): void { - getVSCode()?.postMessage({ command: "openGlobalConfig" }); +function postMessage(command: string): void { + getVSCode()?.postMessage({ command }); } -function showLibrary(): void { - getVSCode()?.postMessage({ command: "showLibrary" }); -} - -function openUploadWidget(): void { - getVSCode()?.postMessage({ command: "openUploadWidget" }); -} - -function searchAssets(): void { - getVSCode()?.postMessage({ command: "searchAssets" }); -} - -function openWelcomeScreen(): void { - getVSCode()?.postMessage({ command: "openWelcomeScreen" }); -} - -declare global { - interface Window { - openGlobalConfig: typeof openGlobalConfig; - showLibrary: typeof showLibrary; - openUploadWidget: typeof openUploadWidget; - searchAssets: typeof searchAssets; - openWelcomeScreen: typeof openWelcomeScreen; - } -} - -window.openGlobalConfig = openGlobalConfig; -window.showLibrary = showLibrary; -window.openUploadWidget = openUploadWidget; -window.searchAssets = searchAssets; -window.openWelcomeScreen = openWelcomeScreen; +document.addEventListener("DOMContentLoaded", () => { + document.getElementById("hs-btn-configure")?.addEventListener("click", () => postMessage("openGlobalConfig")); + document.getElementById("hs-btn-library")?.addEventListener("click", () => postMessage("showLibrary")); + document.getElementById("hs-btn-upload")?.addEventListener("click", () => postMessage("openUploadWidget")); + document.getElementById("hs-btn-search")?.addEventListener("click", () => postMessage("searchAssets")); + document.getElementById("hs-link-welcome")?.addEventListener("click", () => postMessage("openWelcomeScreen")); +}); initCommon(); diff --git a/src/webview/homescreenView.ts b/src/webview/homescreenView.ts index 0b319f1..ec11310 100644 --- a/src/webview/homescreenView.ts +++ b/src/webview/homescreenView.ts @@ -78,24 +78,24 @@ export class HomescreenViewProvider implements vscode.WebviewViewProvider {

${hasConfig ? "Connected" : "Setup required"}

${!hasConfig - ? `` + ? `` : ""}
- - -
- Welcome Guide + Welcome Guide
`; From 31a00f5e10673b56cde6e69a64dbc0165a405535 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Mon, 30 Mar 2026 14:30:47 +0100 Subject: [PATCH 12/72] fix: initialize VS Code API before registering DOM event listeners --- src/webview/client/homescreen.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/webview/client/homescreen.ts b/src/webview/client/homescreen.ts index 4b12618..7e4e28e 100644 --- a/src/webview/client/homescreen.ts +++ b/src/webview/client/homescreen.ts @@ -9,6 +9,8 @@ function postMessage(command: string): void { getVSCode()?.postMessage({ command }); } +initCommon(); + document.addEventListener("DOMContentLoaded", () => { document.getElementById("hs-btn-configure")?.addEventListener("click", () => postMessage("openGlobalConfig")); document.getElementById("hs-btn-library")?.addEventListener("click", () => postMessage("showLibrary")); @@ -16,5 +18,3 @@ document.addEventListener("DOMContentLoaded", () => { document.getElementById("hs-btn-search")?.addEventListener("click", () => postMessage("searchAssets")); document.getElementById("hs-link-welcome")?.addEventListener("click", () => postMessage("openWelcomeScreen")); }); - -initCommon(); From 3fa3e3f4efa1e7dfe9ab6491de0ac86ad47d91b2 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Mon, 30 Mar 2026 14:35:39 +0100 Subject: [PATCH 13/72] feat: redesign homescreen with Cloudinary branding and AI tools placeholder --- src/webview/client/homescreen.ts | 1 - src/webview/homescreenView.ts | 353 +++++++++++++++++++++++++++++-- 2 files changed, 331 insertions(+), 23 deletions(-) diff --git a/src/webview/client/homescreen.ts b/src/webview/client/homescreen.ts index 7e4e28e..314dd86 100644 --- a/src/webview/client/homescreen.ts +++ b/src/webview/client/homescreen.ts @@ -15,6 +15,5 @@ document.addEventListener("DOMContentLoaded", () => { document.getElementById("hs-btn-configure")?.addEventListener("click", () => postMessage("openGlobalConfig")); document.getElementById("hs-btn-library")?.addEventListener("click", () => postMessage("showLibrary")); document.getElementById("hs-btn-upload")?.addEventListener("click", () => postMessage("openUploadWidget")); - document.getElementById("hs-btn-search")?.addEventListener("click", () => postMessage("searchAssets")); document.getElementById("hs-link-welcome")?.addEventListener("click", () => postMessage("openWelcomeScreen")); }); diff --git a/src/webview/homescreenView.ts b/src/webview/homescreenView.ts index ec11310..aa949d7 100644 --- a/src/webview/homescreenView.ts +++ b/src/webview/homescreenView.ts @@ -52,9 +52,6 @@ export class HomescreenViewProvider implements vscode.WebviewViewProvider { case "openUploadWidget": vscode.commands.executeCommand("cloudinary.openUploadWidget"); break; - case "searchAssets": - vscode.commands.executeCommand("cloudinary.searchAssets"); - break; case "openWelcomeScreen": vscode.commands.executeCommand("cloudinary.openWelcomeScreen"); break; @@ -68,34 +65,346 @@ export class HomescreenViewProvider implements vscode.WebviewViewProvider { const cloudName = escapeHtml(this._provider.cloudName || ""); return ` -
-
-
- ${hasConfig ? "✓" : "⚠"} + + +
+
+
+ + Cloudinary
-
-
${hasConfig ? cloudName : "Not Configured"}
-

${hasConfig ? "Connected" : "Setup required"}

+
+ ${hasConfig ? cloudName : "Not configured"} + + + ${hasConfig ? "Connected" : "Setup needed"} +
- ${!hasConfig - ? `` - : ""}
-
- +
+ ` : ""} + +
+ - -
-
- Welcome Guide +
`; From 5b5012926ffc8461d3c3a59072151764bb0808e8 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Mon, 30 Mar 2026 14:52:28 +0100 Subject: [PATCH 14/72] docs: add env-switch webview refresh design spec --- ...03-30-env-switch-webview-refresh-design.md | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-30-env-switch-webview-refresh-design.md diff --git a/docs/superpowers/specs/2026-03-30-env-switch-webview-refresh-design.md b/docs/superpowers/specs/2026-03-30-env-switch-webview-refresh-design.md new file mode 100644 index 0000000..ee5415c --- /dev/null +++ b/docs/superpowers/specs/2026-03-30-env-switch-webview-refresh-design.md @@ -0,0 +1,131 @@ +# Environment Switch Webview Refresh — Design Spec + +**Date:** 2026-03-30 +**Status:** Approved + +## Overview + +When the user runs `cloudinary.switchEnvironment`, all open webviews must reflect the new environment immediately. Currently the tree view refreshes but the homescreen, upload widget, and any open preview panels remain stale. + +## Architecture + +Add an `onDidChangeEnvironment` event to `CloudinaryTreeDataProvider`. Fire it from `switchEnvironment.ts` after credentials are updated. Subscribe in `extension.ts` to orchestrate updates to all open webviews. + +``` +switchEnvironment.ts + └─ provider.notifyEnvironmentChange() + └─ onDidChangeEnvironment fires + ├─ homescreenProvider.refresh() → re-render header with new cloud name + ├─ resetUploadPanel(provider, context) → dispose + reopen if was open + └─ resetAllPreviewPanels() → replace HTML with placeholder +``` + +The `CloudinaryTreeDataProvider` is the natural owner of this event because it already holds all credential state and its `refresh()` is already the canonical "env changed" signal for the tree view. + +## Component Changes + +### `CloudinaryTreeDataProvider` (`src/tree/treeDataProvider.ts`) + +Add alongside the existing `_onDidChangeTreeData`: + +```typescript +private _onDidChangeEnvironment = new vscode.EventEmitter(); +readonly onDidChangeEnvironment = this._onDidChangeEnvironment.event; + +notifyEnvironmentChange(): void { + this._onDidChangeEnvironment.fire(); +} +``` + +### `switchEnvironment.ts` + +After `provider.refresh(...)`, add: + +```typescript +provider.notifyEnvironmentChange(); +``` + +### `HomescreenViewProvider` (`src/webview/homescreenView.ts`) + +Store the resolved view and expose a `refresh()` method: + +```typescript +private _webviewView: vscode.WebviewView | undefined; + +// In resolveWebviewView, add: +this._webviewView = webviewView; + +// New public method: +refresh(): void { + if (!this._webviewView) { return; } + this._webviewView.webview.html = createWebviewDocument({ + title: "Cloudinary", + webview: this._webviewView.webview, + extensionUri: this._extensionUri, + bodyContent: this._getBodyContent(), + additionalScripts: [getScriptUri(this._webviewView.webview, this._extensionUri, "homescreen.js")], + }); +} +``` + +Re-assigning `.html` is safe; VS Code replaces the webview DOM atomically. + +### `uploadWidget.ts` + +Export a `resetUploadPanel` function: + +```typescript +export function resetUploadPanel( + provider: CloudinaryTreeDataProvider, + context: vscode.ExtensionContext +): void { + if (!uploadPanel) { return; } + uploadPanel.dispose(); // sets uploadPanel = undefined via onDidDispose + openOrRevealUploadPanel(currentFolderPath, provider, context); +} +``` + +This preserves the user's last folder context (`currentFolderPath`) when reopening. + +### `previewAsset.ts` + +Export a `resetAllPreviewPanels` function: + +```typescript +export function resetAllPreviewPanels(extensionUri: vscode.Uri): void { + for (const panel of openPanels.values()) { + panel.webview.html = getEnvChangedPlaceholderHtml(panel.webview, extensionUri); + } +} +``` + +The placeholder HTML is a minimal page saying "Environment changed — close this tab and browse the new environment." Panels are not disposed so the user's tabs remain (they can close them manually). + +### `extension.ts` + +After registering `HomescreenViewProvider`: + +```typescript +context.subscriptions.push( + provider.onDidChangeEnvironment(() => { + homescreenProvider.refresh(); + resetUploadPanel(provider, context); + resetAllPreviewPanels(context.extensionUri); + }) +); +``` + +## Files + +**Modified:** +- `src/tree/treeDataProvider.ts` — add event emitter + `notifyEnvironmentChange()` +- `src/commands/switchEnvironment.ts` — fire `notifyEnvironmentChange()` after `provider.refresh()` +- `src/webview/homescreenView.ts` — store `_webviewView`, add `refresh()` +- `src/commands/uploadWidget.ts` — export `resetUploadPanel()` +- `src/commands/previewAsset.ts` — export `resetAllPreviewPanels()` +- `src/extension.ts` — subscribe to `onDidChangeEnvironment` + +## Future Considerations + +- If the upload panel is ever made multi-instance, `resetUploadPanel` will need to iterate all instances. +- The preview placeholder could offer a "Close all previews" button that posts a message handled by VS Code to dispose the panels, once that UX is desired. From bebca31e90b6d7be46ee190a9b7c855482fa3ed7 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Mon, 30 Mar 2026 14:54:31 +0100 Subject: [PATCH 15/72] docs: add env-switch webview refresh implementation plan --- .../2026-03-30-env-switch-webview-refresh.md | 415 ++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-30-env-switch-webview-refresh.md diff --git a/docs/superpowers/plans/2026-03-30-env-switch-webview-refresh.md b/docs/superpowers/plans/2026-03-30-env-switch-webview-refresh.md new file mode 100644 index 0000000..e2b691f --- /dev/null +++ b/docs/superpowers/plans/2026-03-30-env-switch-webview-refresh.md @@ -0,0 +1,415 @@ +# Environment Switch Webview Refresh — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When `cloudinary.switchEnvironment` completes, all open webviews update: the homescreen shows the new cloud name, the upload panel resets for the new environment, and any open preview panels display a placeholder indicating the asset is from a different environment. + +**Architecture:** Add `onDidChangeEnvironment` event emitter to `CloudinaryTreeDataProvider` as the single notification point. Fire it from `switchEnvironment.ts` (and the config file watcher in `extension.ts`) after credentials are updated. Subscribe in `extension.ts` to call `homescreenProvider.refresh()`, `resetUploadPanel()`, and `resetAllPreviewPanels()`. + +**Tech Stack:** TypeScript, VS Code Extension API (`EventEmitter`, `WebviewPanel`, `WebviewViewProvider`) + +--- + +## File Map + +| Action | File | Responsibility | +|--------|------|----------------| +| Modify | `src/tree/treeDataProvider.ts:38-39` | Add `_onDidChangeEnvironment` emitter + `notifyEnvironmentChange()` method | +| Modify | `src/webview/homescreenView.ts` | Store `_webviewView` ref; add public `refresh()` that re-assigns `.html` | +| Modify | `src/commands/uploadWidget.ts` | Export `resetUploadPanel()` — disposes and reopens the singleton panel if open | +| Modify | `src/commands/previewAsset.ts` | Export `resetAllPreviewPanels()` — replaces each open panel's HTML with an env-changed placeholder | +| Modify | `src/commands/switchEnvironment.ts:78-83` | Call `provider.notifyEnvironmentChange()` after `provider.refresh()` | +| Modify | `src/extension.ts:194-199,227` | Subscribe to `onDidChangeEnvironment`; fire notification in config file watcher too | + +--- + +### Task 1: Add `onDidChangeEnvironment` event to `CloudinaryTreeDataProvider` + +**Files:** +- Modify: `src/tree/treeDataProvider.ts:38-39` + +The tree provider already uses a `vscode.EventEmitter` for tree data changes. Add a second emitter for environment changes alongside it. + +- [ ] **Step 1: Add the emitter and method** + +In `src/tree/treeDataProvider.ts`, after line 39 (`readonly onDidChangeTreeData = this._onDidChangeTreeData.event;`), add: + +```typescript + private _onDidChangeEnvironment = new vscode.EventEmitter(); + readonly onDidChangeEnvironment = this._onDidChangeEnvironment.event; + + /** + * Fires the onDidChangeEnvironment event to notify subscribers that + * credentials have changed to a new environment. + */ + notifyEnvironmentChange(): void { + this._onDidChangeEnvironment.fire(); + } +``` + +The full block around the change (lines 38–42) should look like: + +```typescript + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private _onDidChangeEnvironment = new vscode.EventEmitter(); + readonly onDidChangeEnvironment = this._onDidChangeEnvironment.event; + + /** + * Fires the onDidChangeEnvironment event to notify subscribers that + * credentials have changed to a new environment. + */ + notifyEnvironmentChange(): void { + this._onDidChangeEnvironment.fire(); + } + + /** + * Refreshes the tree data view. + */ + refresh(stateUpdate: Partial = {}, append = false) { +``` + +- [ ] **Step 2: Type-check** + +```bash +npm run check-types +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/tree/treeDataProvider.ts +git commit -m "feat: add onDidChangeEnvironment event to CloudinaryTreeDataProvider" +``` + +--- + +### Task 2: Add `refresh()` to `HomescreenViewProvider` + +**Files:** +- Modify: `src/webview/homescreenView.ts` + +`resolveWebviewView` is called lazily by VS Code once the sidebar is first shown. Store the resolved view so `refresh()` can re-assign `.html` later. + +- [ ] **Step 1: Add `_webviewView` field and `refresh()` method** + +In `src/webview/homescreenView.ts`, change the class body as follows: + +Add a private field declaration after the constructor (between `constructor` and `resolveWebviewView`): + +```typescript + private _webviewView: vscode.WebviewView | undefined; +``` + +In `resolveWebviewView`, add `this._webviewView = webviewView;` as the very first line of the method body: + +```typescript + resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken + ): void { + this._webviewView = webviewView; // ← add this line + + webviewView.webview.options = { +``` + +Add a new public method after `resolveWebviewView` and before `_getBodyContent`: + +```typescript + /** + * Re-renders the homescreen HTML with current credentials. + * Safe to call at any time; no-ops if the view has not been resolved yet. + */ + refresh(): void { + if (!this._webviewView) { + return; + } + const scriptUri = getScriptUri( + this._webviewView.webview, + this._extensionUri, + "homescreen.js" + ); + this._webviewView.webview.html = createWebviewDocument({ + title: "Cloudinary", + webview: this._webviewView.webview, + extensionUri: this._extensionUri, + bodyContent: this._getBodyContent(), + additionalScripts: [scriptUri], + }); + } +``` + +- [ ] **Step 2: Type-check** + +```bash +npm run check-types +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/webview/homescreenView.ts +git commit -m "feat: add refresh() to HomescreenViewProvider" +``` + +--- + +### Task 3: Export `resetUploadPanel()` from `uploadWidget.ts` + +**Files:** +- Modify: `src/commands/uploadWidget.ts` + +The module-level `uploadPanel` singleton tracks whether the panel is open. If open when the environment switches, dispose it and immediately reopen it for the new environment (preserving the last folder path via `currentFolderPath`). + +- [ ] **Step 1: Add the exported function** + +In `src/commands/uploadWidget.ts`, add the following function after the closing `}` of `registerUpload` (after line 75, before `openOrRevealUploadPanel`): + +```typescript +/** + * Resets the upload panel for a new environment. + * If the panel is currently open, disposes it and reopens it with the new + * credentials already loaded in `provider`. No-ops if the panel is closed. + */ +export function resetUploadPanel( + provider: CloudinaryTreeDataProvider, + context: vscode.ExtensionContext +): void { + if (!uploadPanel) { + return; + } + // Dispose first. The onDidDispose handler sets uploadPanel = undefined. + uploadPanel.dispose(); + // Reopen immediately with new env credentials. + openOrRevealUploadPanel(currentFolderPath, provider, context); +} +``` + +- [ ] **Step 2: Type-check** + +```bash +npm run check-types +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/commands/uploadWidget.ts +git commit -m "feat: export resetUploadPanel for environment change handling" +``` + +--- + +### Task 4: Export `resetAllPreviewPanels()` from `previewAsset.ts` + +**Files:** +- Modify: `src/commands/previewAsset.ts` + +The module-level `openPanels: Map` tracks all open preview tabs. When the environment changes, replace each panel's HTML with a minimal placeholder telling the user the asset is from a different environment. + +- [ ] **Step 1: Add the placeholder HTML helper and exported function** + +In `src/commands/previewAsset.ts`, add the following after the closing `}` of `registerPreview` (after line 120): + +```typescript +/** + * Returns placeholder body HTML for an environment-changed preview panel. + */ +function getEnvChangedBodyContent(): string { + return ` +
+ +

Environment changed

+

+ This preview is from a different environment.
+ Close this tab and browse the new environment. +

+
+ `; +} + +/** + * Replaces all open preview panels' HTML with an environment-changed placeholder. + * Called when the active Cloudinary environment switches. + */ +export function resetAllPreviewPanels(extensionUri: vscode.Uri): void { + for (const panel of openPanels.values()) { + panel.webview.html = createWebviewDocument({ + title: "Environment Changed", + webview: panel.webview, + extensionUri, + bodyContent: getEnvChangedBodyContent(), + }); + } +} +``` + +- [ ] **Step 2: Type-check** + +```bash +npm run check-types +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/commands/previewAsset.ts +git commit -m "feat: export resetAllPreviewPanels for environment change handling" +``` + +--- + +### Task 5: Wire up all env-change handlers in `extension.ts` and `switchEnvironment.ts` + +**Files:** +- Modify: `src/commands/switchEnvironment.ts:78-83` +- Modify: `src/extension.ts` + +This is the final integration step. Fire `notifyEnvironmentChange()` from two places where credentials change (`switchEnvironment` command and the config file watcher). Subscribe to `onDidChangeEnvironment` in `extension.ts` and call the three handlers. + +- [ ] **Step 1: Fire `notifyEnvironmentChange()` in `switchEnvironment.ts`** + +In `src/commands/switchEnvironment.ts`, after the `provider.refresh(...)` call (after line 83), add: + +```typescript + provider.notifyEnvironmentChange(); +``` + +The block around lines 78–88 should look like: + +```typescript + provider.refresh({ + folderPath: '', + nextCursor: null, + searchQuery: null, + resourceTypeFilter: 'all' + }); + + provider.notifyEnvironmentChange(); + + vscode.window.showInformationMessage( + `🔄 Switched to ${selected} environment.` + ); +``` + +- [ ] **Step 2: Add imports to `extension.ts`** + +In `src/extension.ts`, add the two new imports to the existing import block at the top: + +```typescript +import { resetUploadPanel } from "./commands/uploadWidget"; +import { resetAllPreviewPanels } from "./commands/previewAsset"; +``` + +- [ ] **Step 3: Subscribe to `onDidChangeEnvironment` in `extension.ts`** + +In `src/extension.ts`, in the `activate()` function, after the block that registers `HomescreenViewProvider` (after line 54, before the `isFirstRun` check), add: + +```typescript + // Refresh all open webviews when the active environment changes. + context.subscriptions.push( + cloudinaryProvider.onDidChangeEnvironment(() => { + homescreenProvider.refresh(); + resetUploadPanel(cloudinaryProvider, context); + resetAllPreviewPanels(context.extensionUri); + }) + ); +``` + +The full section should look like: + +```typescript + // Register homescreen sidebar view + const homescreenProvider = new HomescreenViewProvider(context.extensionUri, cloudinaryProvider); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + HomescreenViewProvider.viewType, + homescreenProvider, + { webviewOptions: { retainContextWhenHidden: true } } + ) + ); + + // Refresh all open webviews when the active environment changes. + context.subscriptions.push( + cloudinaryProvider.onDidChangeEnvironment(() => { + homescreenProvider.refresh(); + resetUploadPanel(cloudinaryProvider, context); + resetAllPreviewPanels(context.extensionUri); + }) + ); + + // Check if this is the first run of the extension + const isFirstRun = context.globalState.get('cloudinary.firstRun', true); +``` + +- [ ] **Step 4: Fire `notifyEnvironmentChange()` in the config file watcher** + +In `src/extension.ts`, inside the `watcher.onDidChange(async () => { ... })` handler (around line 194–199), after the `cloudinaryProvider.refresh(...)` call, add: + +```typescript + cloudinaryProvider.notifyEnvironmentChange(); +``` + +The block around the watcher's refresh call should look like: + +```typescript + cloudinaryProvider.refresh({ + folderPath: '', + nextCursor: null, + searchQuery: null, + resourceTypeFilter: 'all' + }); + + cloudinaryProvider.notifyEnvironmentChange(); + }); +``` + +- [ ] **Step 5: Type-check and build** + +```bash +npm run compile +``` + +Expected: no errors, `dist/extension.js` updated. + +- [ ] **Step 6: Manual smoke test** + +Press `F5` to launch the Extension Development Host. Verify: + +1. Open the Cloudinary sidebar — homescreen shows current cloud name. +2. Open a preview panel (click any asset in the library). +3. Open the upload widget (`cloudinary.openUploadWidget`). +4. Run `cloudinary.switchEnvironment` and pick a different environment. +5. **Homescreen** should immediately show the new cloud name and "Connected" pill. +6. **Upload widget** should close and reopen showing the new cloud name. +7. **Preview panel** should show "Environment changed" with the placeholder message. + +- [ ] **Step 7: Commit** + +```bash +git add src/commands/switchEnvironment.ts src/extension.ts +git commit -m "feat: wire up webview refresh on environment switch" +``` From 48b6c48c03a492ddd57ca73ad207fbb8806d52a7 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Mon, 30 Mar 2026 15:13:59 +0100 Subject: [PATCH 16/72] feat: add onDidChangeEnvironment event to CloudinaryTreeDataProvider --- src/tree/treeDataProvider.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/tree/treeDataProvider.ts b/src/tree/treeDataProvider.ts index 844211f..b7c6250 100644 --- a/src/tree/treeDataProvider.ts +++ b/src/tree/treeDataProvider.ts @@ -38,6 +38,17 @@ export class CloudinaryTreeDataProvider implements vscode.TreeDataProvider(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + private _onDidChangeEnvironment = new vscode.EventEmitter(); + readonly onDidChangeEnvironment = this._onDidChangeEnvironment.event; + + /** + * Fires the onDidChangeEnvironment event to notify subscribers that + * credentials have changed to a new environment. + */ + notifyEnvironmentChange(): void { + this._onDidChangeEnvironment.fire(); + } + /** * Refreshes the tree data view. */ From 43cf622ebe5c61ffeaf3c2e6fe8140122fe168ca Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Mon, 30 Mar 2026 15:16:04 +0100 Subject: [PATCH 17/72] feat: add refresh() to HomescreenViewProvider --- src/webview/homescreenView.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/webview/homescreenView.ts b/src/webview/homescreenView.ts index aa949d7..0f21a7f 100644 --- a/src/webview/homescreenView.ts +++ b/src/webview/homescreenView.ts @@ -16,11 +16,15 @@ export class HomescreenViewProvider implements vscode.WebviewViewProvider { private readonly _provider: CloudinaryTreeDataProvider ) {} + private _webviewView: vscode.WebviewView | undefined; + resolveWebviewView( webviewView: vscode.WebviewView, _context: vscode.WebviewViewResolveContext, _token: vscode.CancellationToken ): void { + this._webviewView = webviewView; + webviewView.webview.options = { enableScripts: true, localResourceRoots: [vscode.Uri.joinPath(this._extensionUri, "media")], @@ -60,6 +64,28 @@ export class HomescreenViewProvider implements vscode.WebviewViewProvider { ); } + /** + * Re-renders the homescreen HTML with current credentials. + * Safe to call at any time; no-ops if the view has not been resolved yet. + */ + refresh(): void { + if (!this._webviewView) { + return; + } + const scriptUri = getScriptUri( + this._webviewView.webview, + this._extensionUri, + "homescreen.js" + ); + this._webviewView.webview.html = createWebviewDocument({ + title: "Cloudinary", + webview: this._webviewView.webview, + extensionUri: this._extensionUri, + bodyContent: this._getBodyContent(), + additionalScripts: [scriptUri], + }); + } + private _getBodyContent(): string { const hasConfig = !!(this._provider.cloudName && this._provider.apiKey); const cloudName = escapeHtml(this._provider.cloudName || ""); From a0efcc3659e047dc890f829755fd378f432358b3 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Mon, 30 Mar 2026 15:18:15 +0100 Subject: [PATCH 18/72] feat: export resetUploadPanel for environment change handling --- src/commands/uploadWidget.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/commands/uploadWidget.ts b/src/commands/uploadWidget.ts index d283a76..ea3a9e4 100644 --- a/src/commands/uploadWidget.ts +++ b/src/commands/uploadWidget.ts @@ -74,6 +74,24 @@ function registerUpload( ); } +/** + * Resets the upload panel for a new environment. + * If the panel is currently open, disposes it and reopens it with the new + * credentials already loaded in `provider`. No-ops if the panel is closed. + */ +export function resetUploadPanel( + provider: CloudinaryTreeDataProvider, + context: vscode.ExtensionContext +): void { + if (!uploadPanel) { + return; + } + // Dispose first. The onDidDispose handler sets uploadPanel = undefined. + uploadPanel.dispose(); + // Reopen immediately with new env credentials. + openOrRevealUploadPanel(currentFolderPath, provider, context); +} + /** * Opens the upload panel or reveals it if already open. */ From af0417e134fa79fb40ed4ac9632ffc906f591877 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Mon, 30 Mar 2026 15:20:14 +0100 Subject: [PATCH 19/72] feat: export resetAllPreviewPanels for environment change handling --- src/commands/previewAsset.ts | 44 ++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/commands/previewAsset.ts b/src/commands/previewAsset.ts index b80687b..3324885 100644 --- a/src/commands/previewAsset.ts +++ b/src/commands/previewAsset.ts @@ -119,6 +119,50 @@ function registerPreview(context: vscode.ExtensionContext) { ); } +/** + * Returns placeholder body HTML for an environment-changed preview panel. + */ +function getEnvChangedBodyContent(): string { + return ` +
+ +

Environment changed

+

+ This preview is from a different environment.
+ Close this tab and browse the new environment. +

+
+ `; +} + +/** + * Replaces all open preview panels' HTML with an environment-changed placeholder. + * Called when the active Cloudinary environment switches. + */ +export function resetAllPreviewPanels(extensionUri: vscode.Uri): void { + for (const panel of openPanels.values()) { + panel.webview.html = createWebviewDocument({ + title: "Environment Changed", + webview: panel.webview, + extensionUri, + bodyContent: getEnvChangedBodyContent(), + }); + } +} + /** * Get asset type icon. */ From b475aaca8147f5c7870709fd390b67b72097e628 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Mon, 30 Mar 2026 15:22:59 +0100 Subject: [PATCH 20/72] feat: wire up webview refresh on environment switch Co-Authored-By: Claude Sonnet 4.6 --- src/commands/switchEnvironment.ts | 2 ++ src/extension.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/commands/switchEnvironment.ts b/src/commands/switchEnvironment.ts index 743d2f6..8600975 100644 --- a/src/commands/switchEnvironment.ts +++ b/src/commands/switchEnvironment.ts @@ -82,6 +82,8 @@ function registerSwitchEnv( resourceTypeFilter: 'all' }); + provider.notifyEnvironmentChange(); + vscode.window.showInformationMessage( `🔄 Switched to ${selected} environment.` ); diff --git a/src/extension.ts b/src/extension.ts index 4bab007..f74eb99 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,6 +12,8 @@ import { CloudinaryTreeDataProvider } from "./tree/treeDataProvider"; import { v2 as cloudinary } from "cloudinary"; import { generateUserAgent } from "./utils/userAgent"; import { HomescreenViewProvider } from "./webview/homescreenView"; +import { resetUploadPanel } from "./commands/uploadWidget"; +import { resetAllPreviewPanels } from "./commands/previewAsset"; let statusBar: vscode.StatusBarItem; @@ -53,6 +55,15 @@ export async function activate(context: vscode.ExtensionContext) { ) ); + // Refresh all open webviews when the active environment changes. + context.subscriptions.push( + cloudinaryProvider.onDidChangeEnvironment(() => { + homescreenProvider.refresh(); + resetUploadPanel(cloudinaryProvider, context); + resetAllPreviewPanels(context.extensionUri); + }) + ); + // Check if this is the first run of the extension const isFirstRun = context.globalState.get('cloudinary.firstRun', true); @@ -197,6 +208,8 @@ export async function activate(context: vscode.ExtensionContext) { searchQuery: null, resourceTypeFilter: 'all' }); + + cloudinaryProvider.notifyEnvironmentChange(); }); context.subscriptions.push(watcher); From d835da9a2b9d7a6d5358f811c7b28191b68c5fc1 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Mon, 30 Mar 2026 15:26:10 +0100 Subject: [PATCH 21/72] fix: null out uploadPanel synchronously before reopening in resetUploadPanel --- src/commands/uploadWidget.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/commands/uploadWidget.ts b/src/commands/uploadWidget.ts index ea3a9e4..72b31f1 100644 --- a/src/commands/uploadWidget.ts +++ b/src/commands/uploadWidget.ts @@ -86,9 +86,8 @@ export function resetUploadPanel( if (!uploadPanel) { return; } - // Dispose first. The onDidDispose handler sets uploadPanel = undefined. uploadPanel.dispose(); - // Reopen immediately with new env credentials. + uploadPanel = undefined; openOrRevealUploadPanel(currentFolderPath, provider, context); } From c64b8664d2429d178d9292b8ebfc69734f342607 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Tue, 31 Mar 2026 10:34:31 +0100 Subject: [PATCH 22/72] fix: show cloud name in upload panel title, fetch presets on env switch, fix preview placeholder layout --- src/commands/previewAsset.ts | 18 ++++-------------- src/commands/uploadWidget.ts | 9 +++++---- src/extension.ts | 2 +- 3 files changed, 10 insertions(+), 19 deletions(-) diff --git a/src/commands/previewAsset.ts b/src/commands/previewAsset.ts index 3324885..f5cee3f 100644 --- a/src/commands/previewAsset.ts +++ b/src/commands/previewAsset.ts @@ -124,23 +124,13 @@ function registerPreview(context: vscode.ExtensionContext) { */ function getEnvChangedBodyContent(): string { return ` -
-
+ -

Environment changed

-

+

Environment changed

+

This preview is from a different environment.
Close this tab and browse the new environment.

diff --git a/src/commands/uploadWidget.ts b/src/commands/uploadWidget.ts index 72b31f1..27be6ae 100644 --- a/src/commands/uploadWidget.ts +++ b/src/commands/uploadWidget.ts @@ -79,13 +79,14 @@ function registerUpload( * If the panel is currently open, disposes it and reopens it with the new * credentials already loaded in `provider`. No-ops if the panel is closed. */ -export function resetUploadPanel( +export async function resetUploadPanel( provider: CloudinaryTreeDataProvider, context: vscode.ExtensionContext -): void { +): Promise { if (!uploadPanel) { return; } + await provider.fetchUploadPresets(); uploadPanel.dispose(); uploadPanel = undefined; openOrRevealUploadPanel(currentFolderPath, provider, context); @@ -230,9 +231,10 @@ function createUploadPanel( provider: CloudinaryTreeDataProvider, context: vscode.ExtensionContext ): vscode.WebviewPanel { + const cloudName = provider.cloudName!; const panel = vscode.window.createWebviewPanel( "cloudinaryUploadWidget", - "Upload to Cloudinary", + `Upload — ${cloudName}`, vscode.ViewColumn.One, { enableScripts: true, @@ -250,7 +252,6 @@ function createUploadPanel( ); const currentPreset = provider.getCurrentUploadPreset() || ""; - const cloudName = provider.cloudName!; const folders = collectFolderOptions(provider); const uploadScriptUri = getScriptUri( diff --git a/src/extension.ts b/src/extension.ts index f74eb99..55e09cd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -59,7 +59,7 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push( cloudinaryProvider.onDidChangeEnvironment(() => { homescreenProvider.refresh(); - resetUploadPanel(cloudinaryProvider, context); + resetUploadPanel(cloudinaryProvider, context).catch(() => {}); resetAllPreviewPanels(context.extensionUri); }) ); From b89c472c93190e3b42df7747152a50d26c81856c Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Tue, 31 Mar 2026 10:45:57 +0100 Subject: [PATCH 23/72] fix: close upload and preview panels on env switch instead of in-place update --- src/commands/previewAsset.ts | 32 ++++---------------------------- src/commands/uploadWidget.ts | 7 +------ src/extension.ts | 4 ++-- 3 files changed, 7 insertions(+), 36 deletions(-) diff --git a/src/commands/previewAsset.ts b/src/commands/previewAsset.ts index f5cee3f..08543c9 100644 --- a/src/commands/previewAsset.ts +++ b/src/commands/previewAsset.ts @@ -120,36 +120,12 @@ function registerPreview(context: vscode.ExtensionContext) { } /** - * Returns placeholder body HTML for an environment-changed preview panel. + * Closes all open preview panels when the active environment switches. + * Panels show assets from the old environment and are no longer valid. */ -function getEnvChangedBodyContent(): string { - return ` -
- -

Environment changed

-

- This preview is from a different environment.
- Close this tab and browse the new environment. -

-
- `; -} - -/** - * Replaces all open preview panels' HTML with an environment-changed placeholder. - * Called when the active Cloudinary environment switches. - */ -export function resetAllPreviewPanels(extensionUri: vscode.Uri): void { +export function resetAllPreviewPanels(): void { for (const panel of openPanels.values()) { - panel.webview.html = createWebviewDocument({ - title: "Environment Changed", - webview: panel.webview, - extensionUri, - bodyContent: getEnvChangedBodyContent(), - }); + panel.dispose(); } } diff --git a/src/commands/uploadWidget.ts b/src/commands/uploadWidget.ts index 27be6ae..f510c9e 100644 --- a/src/commands/uploadWidget.ts +++ b/src/commands/uploadWidget.ts @@ -79,17 +79,12 @@ function registerUpload( * If the panel is currently open, disposes it and reopens it with the new * credentials already loaded in `provider`. No-ops if the panel is closed. */ -export async function resetUploadPanel( - provider: CloudinaryTreeDataProvider, - context: vscode.ExtensionContext -): Promise { +export function resetUploadPanel(): void { if (!uploadPanel) { return; } - await provider.fetchUploadPresets(); uploadPanel.dispose(); uploadPanel = undefined; - openOrRevealUploadPanel(currentFolderPath, provider, context); } /** diff --git a/src/extension.ts b/src/extension.ts index 55e09cd..cb12c29 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -59,8 +59,8 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push( cloudinaryProvider.onDidChangeEnvironment(() => { homescreenProvider.refresh(); - resetUploadPanel(cloudinaryProvider, context).catch(() => {}); - resetAllPreviewPanels(context.extensionUri); + resetUploadPanel(); + resetAllPreviewPanels(); }) ); From f06ba004e160797249c48465a0dcffd3cb159d01 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Tue, 31 Mar 2026 10:55:49 +0100 Subject: [PATCH 24/72] fix: use tabGroups API to close preview panels on env switch --- src/commands/previewAsset.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/commands/previewAsset.ts b/src/commands/previewAsset.ts index 08543c9..128f99c 100644 --- a/src/commands/previewAsset.ts +++ b/src/commands/previewAsset.ts @@ -121,11 +121,18 @@ function registerPreview(context: vscode.ExtensionContext) { /** * Closes all open preview panels when the active environment switches. - * Panels show assets from the old environment and are no longer valid. + * Uses the tab groups API so it works regardless of the openPanels tracking state. */ export function resetAllPreviewPanels(): void { - for (const panel of openPanels.values()) { - panel.dispose(); + for (const tabGroup of vscode.window.tabGroups.all) { + for (const tab of tabGroup.tabs) { + if ( + tab.input instanceof vscode.TabInputWebview && + tab.input.viewType === "cloudinaryAssetPreview" + ) { + vscode.window.tabGroups.close(tab); + } + } } } From 3712ae6d6ce5e079746fff782cdc853e5e1add6c Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Tue, 31 Mar 2026 11:13:49 +0100 Subject: [PATCH 25/72] feat: close preview panels and fix homescreen refresh on env switch - resetAllPreviewPanels now disposes panels via the openPanels Map - HomescreenViewProvider clears _webviewView on dispose so refresh() no-ops safely instead of throwing "Webview is disposed" and halting the onDidChangeEnvironment callback Co-Authored-By: Claude Sonnet 4.6 --- src/commands/previewAsset.ts | 12 ++---------- src/webview/homescreenView.ts | 4 ++++ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/commands/previewAsset.ts b/src/commands/previewAsset.ts index 128f99c..29d208d 100644 --- a/src/commands/previewAsset.ts +++ b/src/commands/previewAsset.ts @@ -121,18 +121,10 @@ function registerPreview(context: vscode.ExtensionContext) { /** * Closes all open preview panels when the active environment switches. - * Uses the tab groups API so it works regardless of the openPanels tracking state. */ export function resetAllPreviewPanels(): void { - for (const tabGroup of vscode.window.tabGroups.all) { - for (const tab of tabGroup.tabs) { - if ( - tab.input instanceof vscode.TabInputWebview && - tab.input.viewType === "cloudinaryAssetPreview" - ) { - vscode.window.tabGroups.close(tab); - } - } + for (const panel of openPanels.values()) { + panel.dispose(); } } diff --git a/src/webview/homescreenView.ts b/src/webview/homescreenView.ts index 0f21a7f..cb9119c 100644 --- a/src/webview/homescreenView.ts +++ b/src/webview/homescreenView.ts @@ -25,6 +25,10 @@ export class HomescreenViewProvider implements vscode.WebviewViewProvider { ): void { this._webviewView = webviewView; + webviewView.onDidDispose(() => { + this._webviewView = undefined; + }); + webviewView.webview.options = { enableScripts: true, localResourceRoots: [vscode.Uri.joinPath(this._extensionUri, "media")], From c12044643d5efc4e6515dda0b283a1f47070f414 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Tue, 31 Mar 2026 14:30:50 +0100 Subject: [PATCH 26/72] docs: add Configure AI Tools design spec Co-Authored-By: Claude Sonnet 4.6 --- .../2026-03-31-configure-ai-tools-design.md | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-31-configure-ai-tools-design.md diff --git a/docs/superpowers/specs/2026-03-31-configure-ai-tools-design.md b/docs/superpowers/specs/2026-03-31-configure-ai-tools-design.md new file mode 100644 index 0000000..ee064f3 --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-configure-ai-tools-design.md @@ -0,0 +1,111 @@ +# Configure AI Tools — Design Spec + +## Goal + +Replace the existing `setupWorkspace` command with `configureAiTools`: a command that lets developers install Cloudinary agent skills from the `cloudinary-devs/skills` GitHub repo into their project, and configure MCP servers. Wired to the "Configure AI Tools" button already present (currently disabled) in the homescreen webview. + +## Architecture + +- **New file**: `src/commands/configureAiTools.ts` — replaces `src/commands/setupWorkspace.ts` +- **Update**: `src/commands/registerCommands.ts` — swap `registerSetupWorkspace` for `registerConfigureAiTools` +- **Update**: `package.json` — rename command ID from `cloudinary.setupWorkspace` to `cloudinary.configureAiTools`, update title to "Configure AI Tools" +- **Update**: `src/webview/homescreenView.ts` — handle `configureAiTools` message from webview +- **Update**: `src/webview/client/homescreen.ts` — enable the "Configure AI Tools" button, send `configureAiTools` message on click + +## Command Flow + +1. User clicks "Configure AI Tools" in the homescreen (or runs the command from the palette) +2. Require an open workspace folder; show error and return if none +3. Show multi-select QuickPick: `[Skills ✓, MCP Config ✓]` +4. If neither selected, return +5. If **Skills** selected → run skills flow (see below) +6. If **MCP Config** selected → run MCP config flow (see below) + +## Skills Flow + +### Fetching the skill list + +Use unauthenticated `fetch` against the GitHub Contents API (no token required once the repo is public; during development access requires existing `gh` auth outside the extension): + +``` +GET https://api.github.com/repos/cloudinary-devs/skills/contents/skills +``` + +Returns an array of directory entries. For each entry with `type === "dir"`, fetch its `SKILL.md` in parallel: + +``` +GET https://api.github.com/repos/cloudinary-devs/skills/contents/skills//SKILL.md +``` + +The response contains `content` (base64-encoded). Decode and parse the YAML frontmatter to extract `name` and `description`. + +### Picker + +Show a `canPickMany: true` QuickPick. Each item: +- `label`: skill name (e.g. `cloudinary-docs`) +- `description`: description from SKILL.md frontmatter +- All items picked by default + +### IDE target selection + +After skill selection, ask which AI tool to install for (single-select QuickPick): + +| Option | Install location | +|--------|-----------------| +| Claude Code | `.claude/skills//SKILL.md` + `.claude/skills//references/` | +| Cursor | `.cursor/rules/.mdc` | +| VS Code (Copilot) | `.github/copilot-instructions.md` | + +### Downloading and writing files + +**Claude Code** +- Write decoded `SKILL.md` content as-is to `.claude/skills//SKILL.md` +- Fetch `GET .../contents/skills//references` (ignore 404 — no references dir) +- For each file in references, fetch and write to `.claude/skills//references/` + +**Cursor** +- Write a single `.cursor/rules/.mdc` file +- Content: SKILL.md content with `name:` line removed from frontmatter (Cursor only uses `description:`) + +**VS Code (Copilot)** +- Target file: `.github/copilot-instructions.md` +- Create if absent; append if present +- Append each selected skill as: + ``` + ## + + ``` +- Separate multiple appended skills with a blank line + +### Overwrite handling + +- Claude Code / Cursor: if the target file already exists, prompt "Overwrite?" before writing (same pattern as existing `setupWorkspace`) +- VS Code Copilot: always appends; no overwrite prompt needed + +## MCP Config Flow + +Carry forward the existing `createMCPConfig` logic from `setupWorkspace.ts` unchanged. Uses the same editor-detection (`detectEditor()`) and path mapping for `.cursor/mcp.json`, `.vscode/mcp.json`, etc. + +## Success Feedback + +After all selected operations complete, show an information message: +`✅ Configured AI tools: ` + +Offer an "Open File" action that opens the first written file. + +## Error Handling + +- No workspace open: `showErrorMessage("Please open a workspace folder first.")` +- Network failure fetching skill list: `showErrorMessage("Failed to fetch skills: ")` +- Network failure downloading a skill file: skip that file, collect errors, show a warning after completion listing which files failed + +## File Map + +| Action | File | +|--------|------| +| Create | `src/commands/configureAiTools.ts` | +| Delete | `src/commands/setupWorkspace.ts` | +| Modify | `src/commands/registerCommands.ts` | +| Modify | `package.json` | +| Modify | `src/webview/homescreenView.ts` | +| Modify | `src/webview/client/homescreen.ts` | From 46197cd074d7b5e1fbf7a1e5d7d22cf325f68c9f Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Wed, 1 Apr 2026 09:51:03 +0100 Subject: [PATCH 27/72] docs: use detectEditor() to pre-select IDE target in skills picker Co-Authored-By: Claude Sonnet 4.6 --- .../specs/2026-03-31-configure-ai-tools-design.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-03-31-configure-ai-tools-design.md b/docs/superpowers/specs/2026-03-31-configure-ai-tools-design.md index ee064f3..548c8ff 100644 --- a/docs/superpowers/specs/2026-03-31-configure-ai-tools-design.md +++ b/docs/superpowers/specs/2026-03-31-configure-ai-tools-design.md @@ -48,13 +48,15 @@ Show a `canPickMany: true` QuickPick. Each item: ### IDE target selection -After skill selection, ask which AI tool to install for (single-select QuickPick): +After skill selection, ask which AI tool to install for (single-select QuickPick). Use `detectEditor()` to pre-select the matching option as the active item — the user can change it but doesn't have to: -| Option | Install location | -|--------|-----------------| -| Claude Code | `.claude/skills//SKILL.md` + `.claude/skills//references/` | -| Cursor | `.cursor/rules/.mdc` | -| VS Code (Copilot) | `.github/copilot-instructions.md` | +| Option | Pre-selected when | Install location | +|--------|------------------|-----------------| +| Claude Code | `unknown` or VS Code without Copilot signal | `.claude/skills//SKILL.md` + `.claude/skills//references/` | +| Cursor | `cursor` | `.cursor/rules/.mdc` | +| VS Code (Copilot) | `vscode` | `.github/copilot-instructions.md` | + +`detectEditor()` returning `windsurf` or `antigravity` falls back to pre-selecting Claude Code (closest equivalent). ### Downloading and writing files From c2d213d52398a12c7aa8a51ae386a15a6d6d0c53 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Wed, 1 Apr 2026 10:01:40 +0100 Subject: [PATCH 28/72] docs: add Configure AI Tools implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-04-01-configure-ai-tools.md | 591 ++++++++++++++++++ 1 file changed, 591 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-01-configure-ai-tools.md diff --git a/docs/superpowers/plans/2026-04-01-configure-ai-tools.md b/docs/superpowers/plans/2026-04-01-configure-ai-tools.md new file mode 100644 index 0000000..f7b81e2 --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-configure-ai-tools.md @@ -0,0 +1,591 @@ +# Configure AI Tools — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the stub `setupWorkspace` command with a fully working `cloudinary.configureAiTools` command that fetches Cloudinary agent skills from GitHub and installs them for Claude Code, Cursor, or VS Code Copilot, and optionally scaffolds an MCP config file. + +**Architecture:** A new `src/commands/configureAiTools.ts` module contains all logic: GitHub API fetching (unauthenticated `fetch`), frontmatter parsing, IDE-specific skill installation, and MCP config scaffolding. The homescreen "Configure AI Tools" button (currently disabled/stub) is wired to trigger this command via a webview message. + +**Tech Stack:** TypeScript, VS Code Extension API, GitHub Contents API (unauthenticated), Node.js `fetch` (available in VS Code's Node 18+ extension host) + +--- + +## File Map + +| Action | File | Responsibility | +|--------|------|----------------| +| Create | `src/commands/configureAiTools.ts` | All command logic: fetch, parse, install, MCP config | +| Modify | `src/commands/registerCommands.ts` | Import and call `registerConfigureAiTools` | +| Modify | `package.json` | Add `cloudinary.configureAiTools` command contribution | +| Modify | `src/webview/homescreenView.ts` | Add `configureAiTools` message case; enable button (add ID, remove `disabled`) | +| Modify | `src/webview/client/homescreen.ts` | Wire click listener on `hs-btn-ai-tools` | + +--- + +### Task 1: Create `configureAiTools.ts` + +**Files:** +- Create: `src/commands/configureAiTools.ts` + +This is the main new file. It contains every helper and the command registration. + +- [ ] **Step 1: Create the file with all imports, types, and utility functions** + +Create `src/commands/configureAiTools.ts` with the following content: + +```typescript +import * as vscode from "vscode"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +type EditorType = "cursor" | "vscode" | "windsurf" | "antigravity" | "unknown"; + +type SkillInfo = { + name: string; + description: string; +}; + +type GitHubEntry = { + name: string; + type: "file" | "dir"; +}; + +type GitHubFile = { + content: string; // base64-encoded + encoding: string; +}; + +// ── Editor detection (same logic as legacy setupWorkspace) ─────────────────── + +function detectEditor(): EditorType { + const uriScheme = vscode.env.uriScheme.toLowerCase(); + if (uriScheme === "cursor") { return "cursor"; } + if (uriScheme === "windsurf") { return "windsurf"; } + if (uriScheme === "antigravity" || uriScheme === "gemini") { return "antigravity"; } + if (uriScheme === "vscode" || uriScheme === "vscode-insiders") { return "vscode"; } + const appName = vscode.env.appName.toLowerCase(); + if (appName.includes("cursor")) { return "cursor"; } + if (appName.includes("windsurf")) { return "windsurf"; } + if (appName.includes("antigravity") || appName.includes("gemini")) { return "antigravity"; } + if (appName.includes("visual studio code") || appName.includes("vscode")) { return "vscode"; } + return "unknown"; +} + +function getMcpFilePath(editor: EditorType): string { + switch (editor) { + case "cursor": return ".cursor/mcp.json"; + case "windsurf": return ".windsurf/mcp.json"; + case "antigravity": return ".agent/mcp_config.json"; + case "vscode": + default: return ".vscode/mcp.json"; + } +} + +// ── GitHub API helpers ─────────────────────────────────────────────────────── + +const SKILLS_BASE = "https://api.github.com/repos/cloudinary-devs/skills/contents"; + +async function githubFetchJson(url: string): Promise { + const response = await fetch(url, { + headers: { Accept: "application/vnd.github+json" }, + }); + if (!response.ok) { + throw new Error(`GitHub API ${response.status}: ${url}`); + } + return response.json() as Promise; +} + +function decodeBase64(encoded: string): string { + // GitHub API returns base64 with newlines — strip them before decoding + return Buffer.from(encoded.replace(/\n/g, ""), "base64").toString("utf-8"); +} + +// ── Frontmatter helpers ────────────────────────────────────────────────────── + +function parseFrontmatter(content: string): Record { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) { return {}; } + const result: Record = {}; + for (const line of match[1].split("\n")) { + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) { continue; } + const key = line.slice(0, colonIdx).trim(); + const value = line.slice(colonIdx + 1).trim(); + if (key) { result[key] = value; } + } + return result; +} + +/** Returns everything after the closing --- of the frontmatter block. */ +function getBodyAfterFrontmatter(content: string): string { + return content.replace(/^---\n[\s\S]*?\n---\n?/, "").trim(); +} + +/** Returns SKILL.md content with the `name:` line removed (Cursor .mdc format). */ +function toMdcContent(content: string): string { + return content.replace(/^(---\n)([\s\S]*?)(\n---)/m, (_, open, body, close) => { + const filtered = body + .split("\n") + .filter((line: string) => !line.startsWith("name:")) + .join("\n"); + return `${open}${filtered}${close}`; + }); +} + +// ── Skill fetching ─────────────────────────────────────────────────────────── + +async function fetchSkillList(): Promise { + const entries = await githubFetchJson(`${SKILLS_BASE}/skills`); + const dirs = entries.filter((e) => e.type === "dir"); + + const results = await Promise.all( + dirs.map(async (dir): Promise => { + try { + const file = await githubFetchJson( + `${SKILLS_BASE}/skills/${dir.name}/SKILL.md` + ); + const content = decodeBase64(file.content); + const fm = parseFrontmatter(content); + return { name: fm.name || dir.name, description: fm.description || "" }; + } catch { + return null; + } + }) + ); + + return results.filter((s): s is SkillInfo => s !== null); +} + +async function fetchSkillContent(skillName: string): Promise { + const file = await githubFetchJson( + `${SKILLS_BASE}/skills/${skillName}/SKILL.md` + ); + return decodeBase64(file.content); +} + +async function fetchReferenceFiles( + skillName: string +): Promise> { + let entries: GitHubEntry[]; + try { + entries = await githubFetchJson( + `${SKILLS_BASE}/skills/${skillName}/references` + ); + } catch { + return []; // no references directory — that's fine + } + + const files = await Promise.all( + entries + .filter((e) => e.type === "file") + .map(async (e) => { + const file = await githubFetchJson( + `${SKILLS_BASE}/skills/${skillName}/references/${e.name}` + ); + return { name: e.name, content: decodeBase64(file.content) }; + }) + ); + return files; +} + +// ── Filesystem helpers ─────────────────────────────────────────────────────── + +async function ensureDir(uri: vscode.Uri): Promise { + try { await vscode.workspace.fs.createDirectory(uri); } catch { /* already exists */ } +} + +/** + * Writes content to uri. If the file already exists, prompts the user before + * overwriting. Returns true if the file was written, false if the user skipped. + */ +async function writeWithOverwriteCheck( + uri: vscode.Uri, + content: string, + label: string +): Promise { + try { + await vscode.workspace.fs.stat(uri); + const answer = await vscode.window.showWarningMessage( + `${label} already exists. Overwrite?`, + "Yes", + "No" + ); + if (answer !== "Yes") { return false; } + } catch { + // file doesn't exist — proceed + } + await ensureDir(vscode.Uri.joinPath(uri, "..")); + await vscode.workspace.fs.writeFile(uri, Buffer.from(content, "utf-8")); + return true; +} + +// ── Skill installation — per IDE ───────────────────────────────────────────── + +async function installForClaudeCode( + rootUri: vscode.Uri, + skillName: string, + skillContent: string, + createdFiles: string[], + errors: string[] +): Promise { + const skillFile = vscode.Uri.joinPath( + rootUri, `.claude/skills/${skillName}/SKILL.md` + ); + const written = await writeWithOverwriteCheck( + skillFile, skillContent, `${skillName}/SKILL.md` + ); + if (!written) { return; } + createdFiles.push(`.claude/skills/${skillName}/SKILL.md`); + + let refs: Array<{ name: string; content: string }>; + try { + refs = await fetchReferenceFiles(skillName); + } catch (err: any) { + errors.push(`${skillName} references: ${err.message}`); + return; + } + + for (const ref of refs) { + try { + const refUri = vscode.Uri.joinPath( + rootUri, `.claude/skills/${skillName}/references/${ref.name}` + ); + await ensureDir(vscode.Uri.joinPath(refUri, "..")); + await vscode.workspace.fs.writeFile(refUri, Buffer.from(ref.content, "utf-8")); + createdFiles.push(`.claude/skills/${skillName}/references/${ref.name}`); + } catch (err: any) { + errors.push(`${skillName}/references/${ref.name}: ${err.message}`); + } + } +} + +async function installForCursor( + rootUri: vscode.Uri, + skillName: string, + skillContent: string, + createdFiles: string[] +): Promise { + const mdcUri = vscode.Uri.joinPath(rootUri, `.cursor/rules/${skillName}.mdc`); + const written = await writeWithOverwriteCheck( + mdcUri, toMdcContent(skillContent), `${skillName}.mdc` + ); + if (written) { createdFiles.push(`.cursor/rules/${skillName}.mdc`); } +} + +async function installForCopilot( + rootUri: vscode.Uri, + skillName: string, + skillContent: string, + createdFiles: string[] +): Promise { + const instructionsUri = vscode.Uri.joinPath( + rootUri, ".github/copilot-instructions.md" + ); + await ensureDir(vscode.Uri.joinPath(rootUri, ".github")); + + let existing = ""; + try { + const bytes = await vscode.workspace.fs.readFile(instructionsUri); + existing = Buffer.from(bytes).toString("utf-8"); + } catch { + // new file + } + + const body = getBodyAfterFrontmatter(skillContent); + const section = `## ${skillName}\n\n${body}\n`; + const separator = existing.length > 0 ? "\n" : ""; + + await vscode.workspace.fs.writeFile( + instructionsUri, + Buffer.from(existing + separator + section, "utf-8") + ); + + if (!createdFiles.includes(".github/copilot-instructions.md")) { + createdFiles.push(".github/copilot-instructions.md"); + } +} + +// ── MCP Config ─────────────────────────────────────────────────────────────── + +async function createMcpConfig( + rootUri: vscode.Uri, + mcpFilePath: string, + createdFiles: string[] +): Promise { + const mcpUri = vscode.Uri.joinPath(rootUri, mcpFilePath); + const written = await writeWithOverwriteCheck( + mcpUri, + JSON.stringify({ mcpServers: {} }, null, 2), + mcpFilePath + ); + if (written) { createdFiles.push(mcpFilePath); } +} + +// ── Command registration ───────────────────────────────────────────────────── + +function registerConfigureAiTools(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.commands.registerCommand("cloudinary.configureAiTools", async () => { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + vscode.window.showErrorMessage("Please open a workspace folder first."); + return; + } + const rootUri = workspaceFolders[0].uri; + + // ── Step 1: what to configure ────────────────────────────────────────── + const options = await vscode.window.showQuickPick( + [ + { label: "Skills", description: "Install Cloudinary agent skills", picked: true }, + { label: "MCP Config", description: "Add MCP server configuration file", picked: true }, + ], + { canPickMany: true, placeHolder: "Select what to configure" } + ); + if (!options || options.length === 0) { return; } + + const createdFiles: string[] = []; + const errors: string[] = []; + + // ── Step 2: skills flow ──────────────────────────────────────────────── + if (options.some((o) => o.label === "Skills")) { + let skills: SkillInfo[]; + try { + skills = await fetchSkillList(); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to fetch skills: ${err.message}`); + return; + } + + const pickedSkills = await vscode.window.showQuickPick( + skills.map((s) => ({ label: s.name, description: s.description, picked: true })), + { canPickMany: true, placeHolder: "Select skills to install" } + ); + if (!pickedSkills || pickedSkills.length === 0) { return; } + + // IDE target — pre-select based on detected editor + const editor = detectEditor(); + const ideOptions: vscode.QuickPickItem[] = [ + { label: "Claude Code", description: "Install to .claude/skills/" }, + { label: "Cursor", description: "Install to .cursor/rules/" }, + { label: "VS Code (Copilot)", description: "Append to .github/copilot-instructions.md" }, + ]; + const defaultLabel = + editor === "cursor" ? "Cursor" : + editor === "vscode" ? "VS Code (Copilot)" : + "Claude Code"; + + const qp = vscode.window.createQuickPick(); + qp.items = ideOptions; + qp.activeItems = ideOptions.filter((o) => o.label === defaultLabel); + qp.placeholder = "Select AI tool to install skills for"; + + const ideTarget = await new Promise((resolve) => { + qp.onDidAccept(() => { resolve(qp.activeItems[0]); qp.dispose(); }); + qp.onDidHide(() => { resolve(undefined); qp.dispose(); }); + qp.show(); + }); + if (!ideTarget) { return; } + + for (const item of pickedSkills) { + const skill = skills.find((s) => s.name === item.label)!; + let content: string; + try { + content = await fetchSkillContent(skill.name); + } catch (err: any) { + errors.push(`${skill.name}: ${err.message}`); + continue; + } + + if (ideTarget.label === "Claude Code") { + await installForClaudeCode(rootUri, skill.name, content, createdFiles, errors); + } else if (ideTarget.label === "Cursor") { + await installForCursor(rootUri, skill.name, content, createdFiles); + } else { + await installForCopilot(rootUri, skill.name, content, createdFiles); + } + } + } + + // ── Step 3: MCP config flow ──────────────────────────────────────────── + if (options.some((o) => o.label === "MCP Config")) { + const editor = detectEditor(); + await createMcpConfig(rootUri, getMcpFilePath(editor), createdFiles); + } + + // ── Step 4: feedback ─────────────────────────────────────────────────── + if (errors.length > 0) { + vscode.window.showWarningMessage( + `Some files could not be downloaded: ${errors.join(", ")}` + ); + } + + if (createdFiles.length > 0) { + const action = await vscode.window.showInformationMessage( + `✅ Configured AI tools: ${createdFiles.join(", ")}`, + "Open File" + ); + if (action === "Open File") { + const doc = await vscode.workspace.openTextDocument( + vscode.Uri.joinPath(rootUri, createdFiles[0]) + ); + vscode.window.showTextDocument(doc); + } + } + }) + ); +} + +export default registerConfigureAiTools; +``` + +- [ ] **Step 2: Type-check** + +```bash +npm run check-types +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/commands/configureAiTools.ts +git commit -m "feat: add configureAiTools command with GitHub skill fetch and IDE-specific installation" +``` + +--- + +### Task 2: Register command in `registerCommands.ts` and `package.json` + +**Files:** +- Modify: `src/commands/registerCommands.ts` +- Modify: `package.json` + +- [ ] **Step 1: Add import and call in `registerCommands.ts`** + +In `src/commands/registerCommands.ts`, add after the existing imports: + +```typescript +import registerConfigureAiTools from "./configureAiTools"; +``` + +And add at the end of `registerAllCommands`, after `registerWelcomeScreen(context, provider)`: + +```typescript + registerConfigureAiTools(context); +``` + +- [ ] **Step 2: Add command to `package.json`** + +In `package.json`, find the `"contributes": { "commands": [ ... ] }` array and add: + +```json +{ + "command": "cloudinary.configureAiTools", + "title": "Configure AI Tools", + "category": "Cloudinary" +} +``` + +- [ ] **Step 3: Type-check** + +```bash +npm run check-types +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/commands/registerCommands.ts package.json +git commit -m "feat: register cloudinary.configureAiTools command" +``` + +--- + +### Task 3: Wire up the homescreen button + +**Files:** +- Modify: `src/webview/homescreenView.ts` +- Modify: `src/webview/client/homescreen.ts` + +The "Configure AI Tools" button exists in the homescreen HTML but is disabled with no ID and shows a "Soon" chip. Enable it and wire up its click to trigger the command. + +- [ ] **Step 1: Enable the button and add an ID in `homescreenView.ts`** + +In `src/webview/homescreenView.ts`, find (around line 421): + +```html + +``` + +Replace with: + +```html + +``` + +- [ ] **Step 2: Add message handler in `homescreenView.ts`** + +In `src/webview/homescreenView.ts`, inside `webviewView.webview.onDidReceiveMessage`, add a new case after the `openWelcomeScreen` case: + +```typescript + case "configureAiTools": + vscode.commands.executeCommand("cloudinary.configureAiTools"); + break; +``` + +- [ ] **Step 3: Wire click listener in `homescreen.ts`** + +In `src/webview/client/homescreen.ts`, inside the `DOMContentLoaded` listener, add: + +```typescript + document.getElementById("hs-btn-ai-tools")?.addEventListener("click", () => postMessage("configureAiTools")); +``` + +- [ ] **Step 4: Type-check and compile** + +```bash +npm run compile +``` + +Expected: no errors, `dist/extension.js` updated. + +- [ ] **Step 5: Manual smoke test** + +Press `F5` to launch the Extension Development Host. Verify: + +1. Homescreen shows "Configure AI Tools" button — enabled (no "Soon" chip, not greyed out). +2. Click the button → QuickPick appears with "Skills ✓" and "MCP Config ✓". +3. Select both, confirm → skill list QuickPick appears with all three Cloudinary skills checked. +4. Select all skills, confirm → IDE target QuickPick appears pre-selected based on detected editor. +5. Confirm → skills are written to the correct location (e.g. `.claude/skills/cloudinary-docs/SKILL.md`). +6. Success notification shows with "Open File" action. +7. Run command from Command Palette: `Cloudinary: Configure AI Tools` — same flow. + +> **Note:** The `cloudinary-devs/skills` repo is currently private. For smoke testing, you will need a temporary `gh auth token` workaround or wait until the repo is public. The extension itself uses unauthenticated `fetch` which will work once the repo is public. + +- [ ] **Step 6: Commit** + +```bash +git add src/webview/homescreenView.ts src/webview/client/homescreen.ts +git commit -m "feat: enable Configure AI Tools homescreen button and wire up message handler" +``` From 8b54a4b93e63728bdde208844c287dc9c25968e2 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Wed, 1 Apr 2026 10:23:04 +0100 Subject: [PATCH 29/72] feat: add configureAiTools command with GitHub skill fetch and IDE-specific installation --- src/commands/configureAiTools.ts | 402 +++++++++++++++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 src/commands/configureAiTools.ts diff --git a/src/commands/configureAiTools.ts b/src/commands/configureAiTools.ts new file mode 100644 index 0000000..9d5c271 --- /dev/null +++ b/src/commands/configureAiTools.ts @@ -0,0 +1,402 @@ +import * as vscode from "vscode"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +type EditorType = "cursor" | "vscode" | "windsurf" | "antigravity" | "unknown"; + +type SkillInfo = { + name: string; + description: string; +}; + +type GitHubEntry = { + name: string; + type: "file" | "dir"; +}; + +type GitHubFile = { + content: string; // base64-encoded + encoding: string; +}; + +// ── Editor detection (same logic as legacy setupWorkspace) ─────────────────── + +function detectEditor(): EditorType { + const uriScheme = vscode.env.uriScheme.toLowerCase(); + if (uriScheme === "cursor") { return "cursor"; } + if (uriScheme === "windsurf") { return "windsurf"; } + if (uriScheme === "antigravity" || uriScheme === "gemini") { return "antigravity"; } + if (uriScheme === "vscode" || uriScheme === "vscode-insiders") { return "vscode"; } + const appName = vscode.env.appName.toLowerCase(); + if (appName.includes("cursor")) { return "cursor"; } + if (appName.includes("windsurf")) { return "windsurf"; } + if (appName.includes("antigravity") || appName.includes("gemini")) { return "antigravity"; } + if (appName.includes("visual studio code") || appName.includes("vscode")) { return "vscode"; } + return "unknown"; +} + +function getMcpFilePath(editor: EditorType): string { + switch (editor) { + case "cursor": return ".cursor/mcp.json"; + case "windsurf": return ".windsurf/mcp.json"; + case "antigravity": return ".agent/mcp_config.json"; + case "vscode": + default: return ".vscode/mcp.json"; + } +} + +// ── GitHub API helpers ─────────────────────────────────────────────────────── + +const SKILLS_BASE = "https://api.github.com/repos/cloudinary-devs/skills/contents"; + +async function githubFetchJson(url: string): Promise { + const response = await fetch(url, { + headers: { Accept: "application/vnd.github+json" }, + }); + if (!response.ok) { + throw new Error(`GitHub API ${response.status}: ${url}`); + } + return response.json() as Promise; +} + +function decodeBase64(encoded: string): string { + // GitHub API returns base64 with newlines — strip them before decoding + return Buffer.from(encoded.replace(/\n/g, ""), "base64").toString("utf-8"); +} + +// ── Frontmatter helpers ────────────────────────────────────────────────────── + +function parseFrontmatter(content: string): Record { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) { return {}; } + const result: Record = {}; + for (const line of match[1].split("\n")) { + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) { continue; } + const key = line.slice(0, colonIdx).trim(); + const value = line.slice(colonIdx + 1).trim(); + if (key) { result[key] = value; } + } + return result; +} + +/** Returns everything after the closing --- of the frontmatter block. */ +function getBodyAfterFrontmatter(content: string): string { + return content.replace(/^---\n[\s\S]*?\n---\n?/, "").trim(); +} + +/** Returns SKILL.md content with the `name:` line removed (Cursor .mdc format). */ +function toMdcContent(content: string): string { + return content.replace(/^(---\n)([\s\S]*?)(\n---)/m, (_, open, body, close) => { + const filtered = body + .split("\n") + .filter((line: string) => !line.startsWith("name:")) + .join("\n"); + return `${open}${filtered}${close}`; + }); +} + +// ── Skill fetching ─────────────────────────────────────────────────────────── + +async function fetchSkillList(): Promise { + const entries = await githubFetchJson(`${SKILLS_BASE}/skills`); + const dirs = entries.filter((e) => e.type === "dir"); + + const results = await Promise.all( + dirs.map(async (dir): Promise => { + try { + const file = await githubFetchJson( + `${SKILLS_BASE}/skills/${dir.name}/SKILL.md` + ); + const content = decodeBase64(file.content); + const fm = parseFrontmatter(content); + return { name: fm.name || dir.name, description: fm.description || "" }; + } catch { + return null; + } + }) + ); + + return results.filter((s): s is SkillInfo => s !== null); +} + +async function fetchSkillContent(skillName: string): Promise { + const file = await githubFetchJson( + `${SKILLS_BASE}/skills/${skillName}/SKILL.md` + ); + return decodeBase64(file.content); +} + +async function fetchReferenceFiles( + skillName: string +): Promise> { + let entries: GitHubEntry[]; + try { + entries = await githubFetchJson( + `${SKILLS_BASE}/skills/${skillName}/references` + ); + } catch { + return []; // no references directory — that's fine + } + + const files = await Promise.all( + entries + .filter((e) => e.type === "file") + .map(async (e) => { + const file = await githubFetchJson( + `${SKILLS_BASE}/skills/${skillName}/references/${e.name}` + ); + return { name: e.name, content: decodeBase64(file.content) }; + }) + ); + return files; +} + +// ── Filesystem helpers ─────────────────────────────────────────────────────── + +async function ensureDir(uri: vscode.Uri): Promise { + try { await vscode.workspace.fs.createDirectory(uri); } catch { /* already exists */ } +} + +/** + * Writes content to uri. If the file already exists, prompts the user before + * overwriting. Returns true if the file was written, false if the user skipped. + */ +async function writeWithOverwriteCheck( + uri: vscode.Uri, + content: string, + label: string +): Promise { + try { + await vscode.workspace.fs.stat(uri); + const answer = await vscode.window.showWarningMessage( + `${label} already exists. Overwrite?`, + "Yes", + "No" + ); + if (answer !== "Yes") { return false; } + } catch { + // file doesn't exist — proceed + } + await ensureDir(vscode.Uri.joinPath(uri, "..")); + await vscode.workspace.fs.writeFile(uri, Buffer.from(content, "utf-8")); + return true; +} + +// ── Skill installation — per IDE ───────────────────────────────────────────── + +async function installForClaudeCode( + rootUri: vscode.Uri, + skillName: string, + skillContent: string, + createdFiles: string[], + errors: string[] +): Promise { + const skillFile = vscode.Uri.joinPath( + rootUri, `.claude/skills/${skillName}/SKILL.md` + ); + const written = await writeWithOverwriteCheck( + skillFile, skillContent, `${skillName}/SKILL.md` + ); + if (!written) { return; } + createdFiles.push(`.claude/skills/${skillName}/SKILL.md`); + + let refs: Array<{ name: string; content: string }>; + try { + refs = await fetchReferenceFiles(skillName); + } catch (err: any) { + errors.push(`${skillName} references: ${err.message}`); + return; + } + + for (const ref of refs) { + try { + const refUri = vscode.Uri.joinPath( + rootUri, `.claude/skills/${skillName}/references/${ref.name}` + ); + await ensureDir(vscode.Uri.joinPath(refUri, "..")); + await vscode.workspace.fs.writeFile(refUri, Buffer.from(ref.content, "utf-8")); + createdFiles.push(`.claude/skills/${skillName}/references/${ref.name}`); + } catch (err: any) { + errors.push(`${skillName}/references/${ref.name}: ${err.message}`); + } + } +} + +async function installForCursor( + rootUri: vscode.Uri, + skillName: string, + skillContent: string, + createdFiles: string[] +): Promise { + const mdcUri = vscode.Uri.joinPath(rootUri, `.cursor/rules/${skillName}.mdc`); + const written = await writeWithOverwriteCheck( + mdcUri, toMdcContent(skillContent), `${skillName}.mdc` + ); + if (written) { createdFiles.push(`.cursor/rules/${skillName}.mdc`); } +} + +async function installForCopilot( + rootUri: vscode.Uri, + skillName: string, + skillContent: string, + createdFiles: string[] +): Promise { + const instructionsUri = vscode.Uri.joinPath( + rootUri, ".github/copilot-instructions.md" + ); + await ensureDir(vscode.Uri.joinPath(rootUri, ".github")); + + let existing = ""; + try { + const bytes = await vscode.workspace.fs.readFile(instructionsUri); + existing = Buffer.from(bytes).toString("utf-8"); + } catch { + // new file + } + + const body = getBodyAfterFrontmatter(skillContent); + const section = `## ${skillName}\n\n${body}\n`; + const separator = existing.length > 0 ? "\n" : ""; + + await vscode.workspace.fs.writeFile( + instructionsUri, + Buffer.from(existing + separator + section, "utf-8") + ); + + if (!createdFiles.includes(".github/copilot-instructions.md")) { + createdFiles.push(".github/copilot-instructions.md"); + } +} + +// ── MCP Config ─────────────────────────────────────────────────────────────── + +async function createMcpConfig( + rootUri: vscode.Uri, + mcpFilePath: string, + createdFiles: string[] +): Promise { + const mcpUri = vscode.Uri.joinPath(rootUri, mcpFilePath); + const written = await writeWithOverwriteCheck( + mcpUri, + JSON.stringify({ mcpServers: {} }, null, 2), + mcpFilePath + ); + if (written) { createdFiles.push(mcpFilePath); } +} + +// ── Command registration ───────────────────────────────────────────────────── + +function registerConfigureAiTools(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.commands.registerCommand("cloudinary.configureAiTools", async () => { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + vscode.window.showErrorMessage("Please open a workspace folder first."); + return; + } + const rootUri = workspaceFolders[0].uri; + + // ── Step 1: what to configure ────────────────────────────────────────── + const options = await vscode.window.showQuickPick( + [ + { label: "Skills", description: "Install Cloudinary agent skills", picked: true }, + { label: "MCP Config", description: "Add MCP server configuration file", picked: true }, + ], + { canPickMany: true, placeHolder: "Select what to configure" } + ); + if (!options || options.length === 0) { return; } + + const createdFiles: string[] = []; + const errors: string[] = []; + + // ── Step 2: skills flow ──────────────────────────────────────────────── + if (options.some((o) => o.label === "Skills")) { + let skills: SkillInfo[]; + try { + skills = await fetchSkillList(); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to fetch skills: ${err.message}`); + return; + } + + const pickedSkills = await vscode.window.showQuickPick( + skills.map((s) => ({ label: s.name, description: s.description, picked: true })), + { canPickMany: true, placeHolder: "Select skills to install" } + ); + if (!pickedSkills || pickedSkills.length === 0) { return; } + + // IDE target — pre-select based on detected editor + const editor = detectEditor(); + const ideOptions: vscode.QuickPickItem[] = [ + { label: "Claude Code", description: "Install to .claude/skills/" }, + { label: "Cursor", description: "Install to .cursor/rules/" }, + { label: "VS Code (Copilot)", description: "Append to .github/copilot-instructions.md" }, + ]; + const defaultLabel = + editor === "cursor" ? "Cursor" : + editor === "vscode" ? "VS Code (Copilot)" : + "Claude Code"; + + const qp = vscode.window.createQuickPick(); + qp.items = ideOptions; + qp.activeItems = ideOptions.filter((o) => o.label === defaultLabel); + qp.placeholder = "Select AI tool to install skills for"; + + const ideTarget = await new Promise((resolve) => { + qp.onDidAccept(() => { resolve(qp.activeItems[0]); qp.dispose(); }); + qp.onDidHide(() => { resolve(undefined); qp.dispose(); }); + qp.show(); + }); + if (!ideTarget) { return; } + + for (const item of pickedSkills) { + const skill = skills.find((s) => s.name === item.label)!; + let content: string; + try { + content = await fetchSkillContent(skill.name); + } catch (err: any) { + errors.push(`${skill.name}: ${err.message}`); + continue; + } + + if (ideTarget.label === "Claude Code") { + await installForClaudeCode(rootUri, skill.name, content, createdFiles, errors); + } else if (ideTarget.label === "Cursor") { + await installForCursor(rootUri, skill.name, content, createdFiles); + } else { + await installForCopilot(rootUri, skill.name, content, createdFiles); + } + } + } + + // ── Step 3: MCP config flow ──────────────────────────────────────────── + if (options.some((o) => o.label === "MCP Config")) { + const editor = detectEditor(); + await createMcpConfig(rootUri, getMcpFilePath(editor), createdFiles); + } + + // ── Step 4: feedback ─────────────────────────────────────────────────── + if (errors.length > 0) { + vscode.window.showWarningMessage( + `Some files could not be downloaded: ${errors.join(", ")}` + ); + } + + if (createdFiles.length > 0) { + const action = await vscode.window.showInformationMessage( + `✅ Configured AI tools: ${createdFiles.join(", ")}`, + "Open File" + ); + if (action === "Open File") { + const doc = await vscode.workspace.openTextDocument( + vscode.Uri.joinPath(rootUri, createdFiles[0]) + ); + vscode.window.showTextDocument(doc); + } + } + }) + ); +} + +export default registerConfigureAiTools; From 8267896ebe28f559b35e73540402145d40178d59 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Wed, 1 Apr 2026 10:27:43 +0100 Subject: [PATCH 30/72] fix: correct dir name usage, dedup copilot sections, per-file ref error handling Co-Authored-By: Claude Sonnet 4.6 --- src/commands/configureAiTools.ts | 41 +++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/commands/configureAiTools.ts b/src/commands/configureAiTools.ts index 9d5c271..14fdc30 100644 --- a/src/commands/configureAiTools.ts +++ b/src/commands/configureAiTools.ts @@ -7,6 +7,7 @@ type EditorType = "cursor" | "vscode" | "windsurf" | "antigravity" | "unknown"; type SkillInfo = { name: string; description: string; + dirName: string; // GitHub directory name, used for API paths and local install paths }; type GitHubEntry = { @@ -87,7 +88,7 @@ function getBodyAfterFrontmatter(content: string): string { /** Returns SKILL.md content with the `name:` line removed (Cursor .mdc format). */ function toMdcContent(content: string): string { - return content.replace(/^(---\n)([\s\S]*?)(\n---)/m, (_, open, body, close) => { + return content.replace(/^(---\n)([\s\S]*?)(\n---)/, (_, open, body, close) => { const filtered = body .split("\n") .filter((line: string) => !line.startsWith("name:")) @@ -110,7 +111,7 @@ async function fetchSkillList(): Promise { ); const content = decodeBase64(file.content); const fm = parseFrontmatter(content); - return { name: fm.name || dir.name, description: fm.description || "" }; + return { name: fm.name || dir.name, description: fm.description || "", dirName: dir.name }; } catch { return null; } @@ -139,17 +140,21 @@ async function fetchReferenceFiles( return []; // no references directory — that's fine } - const files = await Promise.all( + const results = await Promise.all( entries .filter((e) => e.type === "file") .map(async (e) => { - const file = await githubFetchJson( - `${SKILLS_BASE}/skills/${skillName}/references/${e.name}` - ); - return { name: e.name, content: decodeBase64(file.content) }; + try { + const file = await githubFetchJson( + `${SKILLS_BASE}/skills/${skillName}/references/${e.name}` + ); + return { name: e.name, content: decodeBase64(file.content) }; + } catch { + return null; + } }) ); - return files; + return results.filter((f): f is { name: string; content: string } => f !== null); } // ── Filesystem helpers ─────────────────────────────────────────────────────── @@ -255,6 +260,13 @@ async function installForCopilot( // new file } + if (existing.includes(`## ${skillName}`)) { + if (!createdFiles.includes(".github/copilot-instructions.md")) { + createdFiles.push(".github/copilot-instructions.md"); + } + return; + } + const body = getBodyAfterFrontmatter(skillContent); const section = `## ${skillName}\n\n${body}\n`; const separator = existing.length > 0 ? "\n" : ""; @@ -351,21 +363,22 @@ function registerConfigureAiTools(context: vscode.ExtensionContext): void { if (!ideTarget) { return; } for (const item of pickedSkills) { - const skill = skills.find((s) => s.name === item.label)!; + const skill = skills.find((s) => s.name === item.label); + if (!skill) { continue; } let content: string; try { - content = await fetchSkillContent(skill.name); + content = await fetchSkillContent(skill.dirName); } catch (err: any) { - errors.push(`${skill.name}: ${err.message}`); + errors.push(`${skill.dirName}: ${err.message}`); continue; } if (ideTarget.label === "Claude Code") { - await installForClaudeCode(rootUri, skill.name, content, createdFiles, errors); + await installForClaudeCode(rootUri, skill.dirName, content, createdFiles, errors); } else if (ideTarget.label === "Cursor") { - await installForCursor(rootUri, skill.name, content, createdFiles); + await installForCursor(rootUri, skill.dirName, content, createdFiles); } else { - await installForCopilot(rootUri, skill.name, content, createdFiles); + await installForCopilot(rootUri, skill.dirName, content, createdFiles); } } } From f40f2c0b4776b3a6ed18e51776dc5f3f96ca24a1 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Wed, 1 Apr 2026 10:29:11 +0100 Subject: [PATCH 31/72] feat: register cloudinary.configureAiTools command --- package.json | 5 +++++ src/commands/registerCommands.ts | 2 ++ 2 files changed, 7 insertions(+) diff --git a/package.json b/package.json index b655f79..8654c94 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,11 @@ "command": "cloudinary.showLibrary", "title": "Browse Media Library", "category": "Cloudinary" + }, + { + "command": "cloudinary.configureAiTools", + "title": "Configure AI Tools", + "category": "Cloudinary" } ], "menus": { diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts index 3b11f54..be10bd9 100644 --- a/src/commands/registerCommands.ts +++ b/src/commands/registerCommands.ts @@ -7,6 +7,7 @@ import registerClipboard from "./copyCommands"; import registerSwitchEnv from "./switchEnvironment"; import registerClearSearch from "./clearSearch"; import registerWelcomeScreen from "./welcomeScreen"; +import registerConfigureAiTools from "./configureAiTools"; import { CloudinaryTreeDataProvider } from "../tree/treeDataProvider"; /** @@ -52,6 +53,7 @@ function registerAllCommands( registerClipboard(context); registerSwitchEnv(context, provider, statusBar); registerWelcomeScreen(context, provider); + registerConfigureAiTools(context); } export { registerAllCommands }; From 9c3ad95f4e32e33a392b2b0167403e8949d4ea9a Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Wed, 1 Apr 2026 10:30:42 +0100 Subject: [PATCH 32/72] feat: enable Configure AI Tools homescreen button and wire up message handler --- src/webview/client/homescreen.ts | 1 + src/webview/homescreenView.ts | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/webview/client/homescreen.ts b/src/webview/client/homescreen.ts index 314dd86..a98db69 100644 --- a/src/webview/client/homescreen.ts +++ b/src/webview/client/homescreen.ts @@ -16,4 +16,5 @@ document.addEventListener("DOMContentLoaded", () => { document.getElementById("hs-btn-library")?.addEventListener("click", () => postMessage("showLibrary")); document.getElementById("hs-btn-upload")?.addEventListener("click", () => postMessage("openUploadWidget")); document.getElementById("hs-link-welcome")?.addEventListener("click", () => postMessage("openWelcomeScreen")); + document.getElementById("hs-btn-ai-tools")?.addEventListener("click", () => postMessage("configureAiTools")); }); diff --git a/src/webview/homescreenView.ts b/src/webview/homescreenView.ts index cb9119c..c8a043b 100644 --- a/src/webview/homescreenView.ts +++ b/src/webview/homescreenView.ts @@ -63,6 +63,9 @@ export class HomescreenViewProvider implements vscode.WebviewViewProvider { case "openWelcomeScreen": vscode.commands.executeCommand("cloudinary.openWelcomeScreen"); break; + case "configureAiTools": + vscode.commands.executeCommand("cloudinary.configureAiTools"); + break; } } ); @@ -418,7 +421,7 @@ export class HomescreenViewProvider implements vscode.WebviewViewProvider { -
From 58ef5147e890b877a3150ef034a681e00ffe232c Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Wed, 1 Apr 2026 10:36:45 +0100 Subject: [PATCH 33/72] fix: catch install errors for Cursor/Copilot and show feedback when no files written Co-Authored-By: Claude Sonnet 4.6 --- src/commands/configureAiTools.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/commands/configureAiTools.ts b/src/commands/configureAiTools.ts index 14fdc30..443e11c 100644 --- a/src/commands/configureAiTools.ts +++ b/src/commands/configureAiTools.ts @@ -376,9 +376,17 @@ function registerConfigureAiTools(context: vscode.ExtensionContext): void { if (ideTarget.label === "Claude Code") { await installForClaudeCode(rootUri, skill.dirName, content, createdFiles, errors); } else if (ideTarget.label === "Cursor") { - await installForCursor(rootUri, skill.dirName, content, createdFiles); + try { + await installForCursor(rootUri, skill.dirName, content, createdFiles); + } catch (err) { + errors.push(`${skill.dirName}: ${err instanceof Error ? err.message : String(err)}`); + } } else { - await installForCopilot(rootUri, skill.dirName, content, createdFiles); + try { + await installForCopilot(rootUri, skill.name, content, createdFiles); + } catch (err) { + errors.push(`${skill.name}: ${err instanceof Error ? err.message : String(err)}`); + } } } } @@ -407,6 +415,8 @@ function registerConfigureAiTools(context: vscode.ExtensionContext): void { ); vscode.window.showTextDocument(doc); } + } else if (errors.length === 0) { + vscode.window.showInformationMessage("No files were written — all targets already exist."); } }) ); From f4ed82fd76b7213284950fe1916881b3cae6170f Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Wed, 1 Apr 2026 11:16:55 +0100 Subject: [PATCH 34/72] feat: populate MCP config with Cloudinary servers + add GitHub auth fallback - Replace empty mcpServers stub with a multi-select picker of real Cloudinary MCP servers (Asset Management, Environment Config, Structured Metadata, Analysis, MediaFlows) - Merges into existing config file rather than overwriting, using the correct root key ("servers" for VS Code, "mcpServers" for others) - VS Code format uses ${env:VAR} references; Cursor/others use plain placeholder strings - Add silent GitHub auth token fallback to githubFetchJson so the private skills repo works during development; degrades gracefully to unauthenticated once the repo is public Co-Authored-By: Claude Sonnet 4.6 --- src/commands/configureAiTools.ts | 191 +++++++++++++++++++++++++++++-- 1 file changed, 183 insertions(+), 8 deletions(-) diff --git a/src/commands/configureAiTools.ts b/src/commands/configureAiTools.ts index 443e11c..92e3de2 100644 --- a/src/commands/configureAiTools.ts +++ b/src/commands/configureAiTools.ts @@ -4,6 +4,16 @@ import * as vscode from "vscode"; type EditorType = "cursor" | "vscode" | "windsurf" | "antigravity" | "unknown"; +type McpServerDef = { + label: string; + description: string; + key: string; + /** Config entry for Cursor / windsurf / antigravity / Claude (mcpServers format) */ + cursorConfig: Record; + /** Config entry for VS Code (servers format, "servers" root key) */ + vscodeConfig: Record; +}; + type SkillInfo = { name: string; description: string; @@ -51,9 +61,18 @@ function getMcpFilePath(editor: EditorType): string { const SKILLS_BASE = "https://api.github.com/repos/cloudinary-devs/skills/contents"; async function githubFetchJson(url: string): Promise { - const response = await fetch(url, { - headers: { Accept: "application/vnd.github+json" }, - }); + const headers: Record = { Accept: "application/vnd.github+json" }; + + try { + const session = await vscode.authentication.getSession("github", ["repo"], { silent: true }); + if (session) { + headers["Authorization"] = `Bearer ${session.accessToken}`; + } + } catch { + // auth not available — proceed unauthenticated + } + + const response = await fetch(url, { headers }); if (!response.ok) { throw new Error(`GitHub API ${response.status}: ${url}`); } @@ -281,20 +300,176 @@ async function installForCopilot( } } +// ── MCP Server definitions ──────────────────────────────────────────────────── + +const MCP_SERVERS: McpServerDef[] = [ + { + label: "Cloudinary Asset Management", + description: "Browse, upload, and manage media assets", + key: "cloudinary-asset-mgmt", + cursorConfig: { + command: "npx", + args: ["-y", "@cloudinary/asset-management", "mcp", "start"], + env: { + CLOUDINARY_CLOUD_NAME: "your_cloud_name", + CLOUDINARY_API_KEY: "your_api_key", + CLOUDINARY_API_SECRET: "your_api_secret", + }, + }, + vscodeConfig: { + type: "stdio", + command: "npx", + args: ["-y", "@cloudinary/asset-management", "mcp", "start"], + env: { + CLOUDINARY_CLOUD_NAME: "${env:CLOUDINARY_CLOUD_NAME}", + CLOUDINARY_API_KEY: "${env:CLOUDINARY_API_KEY}", + CLOUDINARY_API_SECRET: "${env:CLOUDINARY_API_SECRET}", + }, + }, + }, + { + label: "Cloudinary Environment Config", + description: "Configure upload presets, transformations, and settings", + key: "cloudinary-env-config", + cursorConfig: { + command: "npx", + args: ["-y", "@cloudinary/environment-config", "mcp", "start"], + env: { + CLOUDINARY_CLOUD_NAME: "your_cloud_name", + CLOUDINARY_API_KEY: "your_api_key", + CLOUDINARY_API_SECRET: "your_api_secret", + }, + }, + vscodeConfig: { + type: "stdio", + command: "npx", + args: ["-y", "@cloudinary/environment-config", "mcp", "start"], + env: { + CLOUDINARY_CLOUD_NAME: "${env:CLOUDINARY_CLOUD_NAME}", + CLOUDINARY_API_KEY: "${env:CLOUDINARY_API_KEY}", + CLOUDINARY_API_SECRET: "${env:CLOUDINARY_API_SECRET}", + }, + }, + }, + { + label: "Cloudinary Structured Metadata", + description: "Manage structured metadata fields and values", + key: "cloudinary-smd", + cursorConfig: { + command: "npx", + args: ["-y", "@cloudinary/structured-metadata", "mcp", "start"], + env: { + CLOUDINARY_CLOUD_NAME: "your_cloud_name", + CLOUDINARY_API_KEY: "your_api_key", + CLOUDINARY_API_SECRET: "your_api_secret", + }, + }, + vscodeConfig: { + type: "stdio", + command: "npx", + args: ["-y", "@cloudinary/structured-metadata", "mcp", "start"], + env: { + CLOUDINARY_CLOUD_NAME: "${env:CLOUDINARY_CLOUD_NAME}", + CLOUDINARY_API_KEY: "${env:CLOUDINARY_API_KEY}", + CLOUDINARY_API_SECRET: "${env:CLOUDINARY_API_SECRET}", + }, + }, + }, + { + label: "Cloudinary Analysis", + description: "AI-powered image and video analysis", + key: "cloudinary-analysis", + cursorConfig: { + command: "npx", + args: ["-y", "@cloudinary/analysis", "mcp", "start"], + env: { + CLOUDINARY_CLOUD_NAME: "your_cloud_name", + CLOUDINARY_API_KEY: "your_api_key", + CLOUDINARY_API_SECRET: "your_api_secret", + }, + }, + vscodeConfig: { + type: "stdio", + command: "npx", + args: ["-y", "@cloudinary/analysis", "mcp", "start"], + env: { + CLOUDINARY_CLOUD_NAME: "${env:CLOUDINARY_CLOUD_NAME}", + CLOUDINARY_API_KEY: "${env:CLOUDINARY_API_KEY}", + CLOUDINARY_API_SECRET: "${env:CLOUDINARY_API_SECRET}", + }, + }, + }, + { + label: "MediaFlows", + description: "AI-powered media workflows and automation", + key: "mediaflows", + cursorConfig: { + url: "https://mediaflows.mcp.cloudinary.com/v2/mcp", + headers: { + "cld-cloud-name": "your_cloud_name", + "cld-api-key": "your_api_key", + "cld-secret": "your_api_secret", + }, + }, + vscodeConfig: { + url: "https://mediaflows.mcp.cloudinary.com/v2/mcp", + headers: { + "cld-cloud-name": "your_cloud_name", + "cld-api-key": "your_api_key", + "cld-secret": "your_api_secret", + }, + }, + }, +]; + // ── MCP Config ─────────────────────────────────────────────────────────────── async function createMcpConfig( rootUri: vscode.Uri, + editor: EditorType, mcpFilePath: string, createdFiles: string[] ): Promise { + const selected = await vscode.window.showQuickPick( + MCP_SERVERS.map((s) => ({ label: s.label, description: s.description, picked: true })), + { canPickMany: true, placeHolder: "Select MCP servers to configure" } + ); + if (!selected || selected.length === 0) { return; } + + const selectedDefs = selected + .map((item) => MCP_SERVERS.find((s) => s.label === item.label)) + .filter((s): s is McpServerDef => s !== undefined); + const mcpUri = vscode.Uri.joinPath(rootUri, mcpFilePath); - const written = await writeWithOverwriteCheck( + const isVscode = editor === "vscode"; + const rootKey = isVscode ? "servers" : "mcpServers"; + + // Read and merge into existing config if present + let config: Record = {}; + try { + const bytes = await vscode.workspace.fs.readFile(mcpUri); + config = JSON.parse(Buffer.from(bytes).toString("utf-8")); + } catch { + // new file + } + + if (!config[rootKey] || typeof config[rootKey] !== "object") { + config[rootKey] = {}; + } + const servers = config[rootKey] as Record; + + for (const def of selectedDefs) { + servers[def.key] = isVscode ? def.vscodeConfig : def.cursorConfig; + } + + await ensureDir(vscode.Uri.joinPath(mcpUri, "..")); + await vscode.workspace.fs.writeFile( mcpUri, - JSON.stringify({ mcpServers: {} }, null, 2), - mcpFilePath + Buffer.from(JSON.stringify(config, null, 2), "utf-8") ); - if (written) { createdFiles.push(mcpFilePath); } + if (!createdFiles.includes(mcpFilePath)) { + createdFiles.push(mcpFilePath); + } } // ── Command registration ───────────────────────────────────────────────────── @@ -394,7 +569,7 @@ function registerConfigureAiTools(context: vscode.ExtensionContext): void { // ── Step 3: MCP config flow ──────────────────────────────────────────── if (options.some((o) => o.label === "MCP Config")) { const editor = detectEditor(); - await createMcpConfig(rootUri, getMcpFilePath(editor), createdFiles); + await createMcpConfig(rootUri, editor, getMcpFilePath(editor), createdFiles); } // ── Step 4: feedback ─────────────────────────────────────────────────── From 5892eb68e89f744755d174f6d126ac5c99645f3c Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Wed, 1 Apr 2026 11:20:55 +0100 Subject: [PATCH 35/72] fix: retry GitHub API request with auth on 401/403/404 Unauthenticated fetch works for public repos with no UI. When the repo is private (or rate-limited), it retries once after prompting the user for GitHub auth (createIfNone: true). Auth prompt is skipped entirely once the repo is public. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/configureAiTools.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/commands/configureAiTools.ts b/src/commands/configureAiTools.ts index 92e3de2..e5c0963 100644 --- a/src/commands/configureAiTools.ts +++ b/src/commands/configureAiTools.ts @@ -61,18 +61,25 @@ function getMcpFilePath(editor: EditorType): string { const SKILLS_BASE = "https://api.github.com/repos/cloudinary-devs/skills/contents"; async function githubFetchJson(url: string): Promise { - const headers: Record = { Accept: "application/vnd.github+json" }; + const baseHeaders: Record = { Accept: "application/vnd.github+json" }; - try { - const session = await vscode.authentication.getSession("github", ["repo"], { silent: true }); - if (session) { - headers["Authorization"] = `Bearer ${session.accessToken}`; + // Try unauthenticated first (works for public repos, no UI) + let response = await fetch(url, { headers: baseHeaders }); + + // On 401/403/404 attempt GitHub auth and retry once + if (!response.ok && [401, 403, 404].includes(response.status)) { + try { + const session = await vscode.authentication.getSession("github", ["repo"], { createIfNone: true }); + if (session) { + response = await fetch(url, { + headers: { ...baseHeaders, Authorization: `Bearer ${session.accessToken}` }, + }); + } + } catch { + // auth declined or unavailable — fall through with original error } - } catch { - // auth not available — proceed unauthenticated } - const response = await fetch(url, { headers }); if (!response.ok) { throw new Error(`GitHub API ${response.status}: ${url}`); } From f7c9363110f7992ee597a2dd15a84702a470108d Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Wed, 1 Apr 2026 11:44:53 +0100 Subject: [PATCH 36/72] docs: add design spec for configure-ai-tools v2 Covers remote MCP servers, IDE picker reorder, and annotated QuickPick for re-entry visibility. Co-Authored-By: Claude Sonnet 4.6 --- ...2026-04-01-configure-ai-tools-v2-design.md | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-01-configure-ai-tools-v2-design.md diff --git a/docs/superpowers/specs/2026-04-01-configure-ai-tools-v2-design.md b/docs/superpowers/specs/2026-04-01-configure-ai-tools-v2-design.md new file mode 100644 index 0000000..5170593 --- /dev/null +++ b/docs/superpowers/specs/2026-04-01-configure-ai-tools-v2-design.md @@ -0,0 +1,101 @@ +# Configure AI Tools v2 — Design Spec + +## Goal + +Two improvements to the existing `configureAiTools` command: + +1. Switch MCP server configs from local `npx` processes to remote OAuth URLs (MediaFlows keeps headers-based auth). +2. Add status annotations to every QuickPick so users can see what's already installed and make informed choices on re-entry. + +## Changes to `src/commands/configureAiTools.ts` only + +No other files need modification. + +--- + +## Remote MCP Server Configs + +Replace all `cursorConfig` / `vscodeConfig` entries for the four Cloudinary LLM MCP servers with remote URL entries. Both formats are identical — no `type` field needed since editors infer remote from the presence of `url`. + +| Server | URL | +|--------|-----| +| cloudinary-asset-mgmt | `https://asset-management.mcp.cloudinary.com/mcp` | +| cloudinary-env-config | `https://environment-config.mcp.cloudinary.com/mcp` | +| cloudinary-smd | `https://structured-metadata.mcp.cloudinary.com/mcp` | +| cloudinary-analysis | `https://analysis.mcp.cloudinary.com/sse` | + +These use OAuth — no credentials in the config file. + +MediaFlows keeps the headers-based format unchanged (it does not use OAuth): + +```json +{ + "url": "https://mediaflows.mcp.cloudinary.com/v2/mcp", + "headers": { + "cld-cloud-name": "your_cloud_name", + "cld-api-key": "your_api_key", + "cld-secret": "your_api_secret" + } +} +``` + +The VS Code (`"servers"`) vs Cursor/others (`"mcpServers"`) root key distinction is unchanged. + +--- + +## Flow Reorder: IDE Picker Before Skills Picker + +The IDE target QuickPick moves **before** the skills picker. This is required so the correct install path is known when checking what's already installed. + +New skills flow order: +1. IDE target picker (single-select, pre-selected via `detectEditor()`) +2. Skills picker (multi-select, annotated with install status for the selected IDE) + +--- + +## Status Annotations + +### Two new helpers + +**`readInstalledSkillDirNames(rootUri, ideTarget)`** → `Set` + +Checks which skill `dirName` values are already present for the given IDE: +- Claude Code: `.claude/skills//SKILL.md` exists +- Cursor: `.cursor/rules/.mdc` exists +- VS Code Copilot: `.github/copilot-instructions.md` contains `## ` (use the frontmatter `name`, not dirName) + +Returns a Set of the dirNames (or skill names for Copilot) that are installed. + +**`readConfiguredMcpServerKeys(rootUri, mcpFilePath, rootKey)`** → `Set` + +Reads the existing MCP config file, parses JSON, returns the keys present under `rootKey` (`"servers"` or `"mcpServers"`). Returns empty Set if the file doesn't exist or can't be parsed. + +### Skills QuickPick annotations + +After calling `readInstalledSkillDirNames`, build each QuickPick item: + +``` +label: skill.name +description: skill.description +detail: "✓ installed" OR "Not installed" +picked: true (always — user decides whether to re-install) +``` + +### MCP servers QuickPick annotations + +After calling `readConfiguredMcpServerKeys`, build each QuickPick item: + +``` +label: server.label +description: server.description +detail: "✓ already configured" OR "Not configured" +picked: false if already configured, true if not configured +``` + +Already-configured MCP servers default to **unchecked** to protect credentials (especially MediaFlows placeholder values) from silent overwrite on re-run. Status is still visible so the user knows what's there. + +--- + +## Error Handling + +No changes to existing error handling. `readInstalledSkillDirNames` and `readConfiguredMcpServerKeys` swallow errors silently (treat as "not installed/configured") — failure to read status is non-fatal. From 1b09b9925822e697f07247b2d732482282957886 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Wed, 1 Apr 2026 13:18:20 +0100 Subject: [PATCH 37/72] docs: add implementation plan for configure-ai-tools v2 Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-04-01-configure-ai-tools-v2.md | 383 ++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-01-configure-ai-tools-v2.md diff --git a/docs/superpowers/plans/2026-04-01-configure-ai-tools-v2.md b/docs/superpowers/plans/2026-04-01-configure-ai-tools-v2.md new file mode 100644 index 0000000..6498a65 --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-configure-ai-tools-v2.md @@ -0,0 +1,383 @@ +# Configure AI Tools v2 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Switch MCP server configs to remote OAuth URLs and add status annotations to every QuickPick so users see what's already installed when re-running the command. + +**Architecture:** All changes are in `src/commands/configureAiTools.ts`. Three sequential tasks: (1) simplify `McpServerDef` and update server URLs, (2) add two status-detection helpers, (3) reorder the skills flow and wire status into both pickers. + +**Tech Stack:** TypeScript, VS Code Extension API (`vscode.workspace.fs`, `vscode.window.showQuickPick`, `vscode.window.createQuickPick`) + +--- + +### Task 1: Simplify McpServerDef and switch to remote OAuth URLs + +**Files:** +- Modify: `src/commands/configureAiTools.ts:7-15` (type), `src/commands/configureAiTools.ts:312-430` (MCP_SERVERS + createMcpConfig) + +The four Cloudinary LLM MCP servers now use remote OAuth endpoints — no credentials in the file. MediaFlows keeps its headers-based config. Since both editors now use the same server entry format (only the root key `"servers"` vs `"mcpServers"` differs), the `McpServerDef` type simplifies from two config fields to one. + +- [ ] **Step 1: Replace the `McpServerDef` type** + +In `src/commands/configureAiTools.ts`, replace lines 7–15: + +```typescript +type McpServerDef = { + label: string; + description: string; + key: string; + config: Record; // same for all editors; root key differs by editor +}; +``` + +- [ ] **Step 2: Replace the entire `MCP_SERVERS` array** + +Replace the `MCP_SERVERS` constant (currently lines 312–430) with: + +```typescript +const MCP_SERVERS: McpServerDef[] = [ + { + label: "Cloudinary Asset Management", + description: "Browse, upload, and manage media assets", + key: "cloudinary-asset-mgmt", + config: { url: "https://asset-management.mcp.cloudinary.com/mcp" }, + }, + { + label: "Cloudinary Environment Config", + description: "Configure upload presets, transformations, and settings", + key: "cloudinary-env-config", + config: { url: "https://environment-config.mcp.cloudinary.com/mcp" }, + }, + { + label: "Cloudinary Structured Metadata", + description: "Manage structured metadata fields and values", + key: "cloudinary-smd", + config: { url: "https://structured-metadata.mcp.cloudinary.com/mcp" }, + }, + { + label: "Cloudinary Analysis", + description: "AI-powered image and video analysis", + key: "cloudinary-analysis", + config: { url: "https://analysis.mcp.cloudinary.com/sse" }, + }, + { + label: "MediaFlows", + description: "AI-powered media workflows and automation", + key: "mediaflows", + config: { + url: "https://mediaflows.mcp.cloudinary.com/v2/mcp", + headers: { + "cld-cloud-name": "your_cloud_name", + "cld-api-key": "your_api_key", + "cld-secret": "your_api_secret", + }, + }, + }, +]; +``` + +- [ ] **Step 3: Update `createMcpConfig` to use `def.config`** + +Inside `createMcpConfig`, find the loop that writes server entries and change it from: +```typescript +servers[def.key] = isVscode ? def.vscodeConfig : def.cursorConfig; +``` +to: +```typescript +servers[def.key] = def.config; +``` + +- [ ] **Step 4: Type-check** + +```bash +npm run check-types +``` + +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/configureAiTools.ts +git commit -m "feat: switch MCP servers to remote OAuth URLs" +``` + +--- + +### Task 2: Add status-detection helpers + +**Files:** +- Modify: `src/commands/configureAiTools.ts` — add two functions in the `// ── MCP Config` section, before `createMcpConfig` + +- [ ] **Step 1: Add `readInstalledSkillDirNames`** + +Insert this function immediately before the `// ── MCP Server definitions` comment: + +```typescript +// ── Status detection ───────────────────────────────────────────────────────── + +/** + * Returns the set of skill dirNames already installed for the given IDE target. + * Errors reading individual paths are silently treated as "not installed". + */ +async function readInstalledSkillDirNames( + rootUri: vscode.Uri, + ideTargetLabel: string, + skills: SkillInfo[] +): Promise> { + const installed = new Set(); + + if (ideTargetLabel === "VS Code (Copilot)") { + try { + const uri = vscode.Uri.joinPath(rootUri, ".github/copilot-instructions.md"); + const bytes = await vscode.workspace.fs.readFile(uri); + const content = Buffer.from(bytes).toString("utf-8"); + for (const skill of skills) { + if (content.includes(`## ${skill.name}`)) { + installed.add(skill.dirName); + } + } + } catch { + // file not found — nothing installed + } + return installed; + } + + await Promise.all( + skills.map(async (skill) => { + try { + const checkPath = + ideTargetLabel === "Claude Code" + ? `.claude/skills/${skill.dirName}/SKILL.md` + : `.cursor/rules/${skill.dirName}.mdc`; + await vscode.workspace.fs.stat(vscode.Uri.joinPath(rootUri, checkPath)); + installed.add(skill.dirName); + } catch { + // not installed + } + }) + ); + return installed; +} +``` + +- [ ] **Step 2: Add `readConfiguredMcpServerKeys`** + +Insert this function immediately after `readInstalledSkillDirNames`: + +```typescript +/** + * Returns the set of server keys already present in the MCP config file. + * Returns an empty Set if the file doesn't exist or can't be parsed. + */ +async function readConfiguredMcpServerKeys( + rootUri: vscode.Uri, + mcpFilePath: string, + rootKey: string +): Promise> { + try { + const uri = vscode.Uri.joinPath(rootUri, mcpFilePath); + const bytes = await vscode.workspace.fs.readFile(uri); + const config = JSON.parse(Buffer.from(bytes).toString("utf-8")); + const servers = config[rootKey]; + if (servers && typeof servers === "object") { + return new Set(Object.keys(servers)); + } + } catch { + // file not found or invalid JSON + } + return new Set(); +} +``` + +- [ ] **Step 3: Type-check** + +```bash +npm run check-types +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/commands/configureAiTools.ts +git commit -m "feat: add skill and MCP status-detection helpers" +``` + +--- + +### Task 3: Reorder skills flow and annotate both QuickPicks + +**Files:** +- Modify: `src/commands/configureAiTools.ts` — the skills block inside `registerConfigureAiTools` (currently lines ~507–573) and `createMcpConfig` + +**Skills flow change:** IDE picker moves before the skills picker so `readInstalledSkillDirNames` can check the right paths before presenting the list. + +**MCP flow change:** `createMcpConfig` calls `readConfiguredMcpServerKeys` before building its picker items. + +- [ ] **Step 1: Replace the entire skills block inside `registerConfigureAiTools`** + +Replace the block from `// ── Step 2: skills flow` through its closing `}` with: + +```typescript + // ── Step 2: skills flow ──────────────────────────────────────────────── + if (options.some((o) => o.label === "Skills")) { + let skills: SkillInfo[]; + try { + skills = await fetchSkillList(); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to fetch skills: ${err.message}`); + return; + } + + // IDE target first — needed to check install status before showing skills + const editor = detectEditor(); + const ideOptions: vscode.QuickPickItem[] = [ + { label: "Claude Code", description: "Install to .claude/skills/" }, + { label: "Cursor", description: "Install to .cursor/rules/" }, + { label: "VS Code (Copilot)", description: "Append to .github/copilot-instructions.md" }, + ]; + const defaultLabel = + editor === "cursor" ? "Cursor" : + editor === "vscode" ? "VS Code (Copilot)" : + "Claude Code"; + + const qp = vscode.window.createQuickPick(); + qp.items = ideOptions; + qp.activeItems = ideOptions.filter((o) => o.label === defaultLabel); + qp.placeholder = "Select AI tool to install skills for"; + + const ideTarget = await new Promise((resolve) => { + qp.onDidAccept(() => { resolve(qp.activeItems[0]); qp.dispose(); }); + qp.onDidHide(() => { resolve(undefined); qp.dispose(); }); + qp.show(); + }); + if (!ideTarget) { return; } + + const installedDirNames = await readInstalledSkillDirNames(rootUri, ideTarget.label, skills); + + const pickedSkills = await vscode.window.showQuickPick( + skills.map((s) => ({ + label: s.name, + description: s.description, + detail: installedDirNames.has(s.dirName) ? "✓ installed" : "Not installed", + picked: true, + })), + { canPickMany: true, placeHolder: "Select skills to install" } + ); + if (!pickedSkills || pickedSkills.length === 0) { return; } + + for (const item of pickedSkills) { + const skill = skills.find((s) => s.name === item.label); + if (!skill) { continue; } + let content: string; + try { + content = await fetchSkillContent(skill.dirName); + } catch (err: any) { + errors.push(`${skill.dirName}: ${err.message}`); + continue; + } + + if (ideTarget.label === "Claude Code") { + await installForClaudeCode(rootUri, skill.dirName, content, createdFiles, errors); + } else if (ideTarget.label === "Cursor") { + try { + await installForCursor(rootUri, skill.dirName, content, createdFiles); + } catch (err) { + errors.push(`${skill.dirName}: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + try { + await installForCopilot(rootUri, skill.name, content, createdFiles); + } catch (err) { + errors.push(`${skill.name}: ${err instanceof Error ? err.message : String(err)}`); + } + } + } + } +``` + +- [ ] **Step 2: Update `createMcpConfig` to annotate the server picker** + +Replace the `showQuickPick` call and the `selectedDefs` derivation inside `createMcpConfig` with: + +```typescript +async function createMcpConfig( + rootUri: vscode.Uri, + editor: EditorType, + mcpFilePath: string, + createdFiles: string[] +): Promise { + const isVscode = editor === "vscode"; + const rootKey = isVscode ? "servers" : "mcpServers"; + const configuredKeys = await readConfiguredMcpServerKeys(rootUri, mcpFilePath, rootKey); + + const selected = await vscode.window.showQuickPick( + MCP_SERVERS.map((s) => ({ + label: s.label, + description: s.description, + detail: configuredKeys.has(s.key) ? "✓ already configured" : "Not configured", + picked: !configuredKeys.has(s.key), + })), + { canPickMany: true, placeHolder: "Select MCP servers to configure" } + ); + if (!selected || selected.length === 0) { return; } + + const selectedDefs = selected + .map((item) => MCP_SERVERS.find((s) => s.label === item.label)) + .filter((s): s is McpServerDef => s !== undefined); + + const mcpUri = vscode.Uri.joinPath(rootUri, mcpFilePath); + + // Read and merge into existing config if present + let config: Record = {}; + try { + const bytes = await vscode.workspace.fs.readFile(mcpUri); + config = JSON.parse(Buffer.from(bytes).toString("utf-8")); + } catch { + // new file + } + + if (!config[rootKey] || typeof config[rootKey] !== "object") { + config[rootKey] = {}; + } + const servers = config[rootKey] as Record; + + for (const def of selectedDefs) { + servers[def.key] = def.config; + } + + await ensureDir(vscode.Uri.joinPath(mcpUri, "..")); + await vscode.workspace.fs.writeFile( + mcpUri, + Buffer.from(JSON.stringify(config, null, 2), "utf-8") + ); + if (!createdFiles.includes(mcpFilePath)) { + createdFiles.push(mcpFilePath); + } +} +``` + +- [ ] **Step 3: Type-check** + +```bash +npm run check-types +``` + +Expected: no errors. + +- [ ] **Step 4: Run test suite** + +```bash +npm run compile-tests && npm run test +``` + +Expected: `1 passing` + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/configureAiTools.ts +git commit -m "feat: reorder skills flow and annotate QuickPicks with install status" +``` From bfefda54a70d3e7d18c2ddf30174834203fd3de2 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Wed, 1 Apr 2026 13:20:58 +0100 Subject: [PATCH 38/72] feat: switch MCP servers to remote OAuth URLs --- src/commands/configureAiTools.ts | 97 +++----------------------------- 1 file changed, 7 insertions(+), 90 deletions(-) diff --git a/src/commands/configureAiTools.ts b/src/commands/configureAiTools.ts index e5c0963..3801fe3 100644 --- a/src/commands/configureAiTools.ts +++ b/src/commands/configureAiTools.ts @@ -8,10 +8,7 @@ type McpServerDef = { label: string; description: string; key: string; - /** Config entry for Cursor / windsurf / antigravity / Claude (mcpServers format) */ - cursorConfig: Record; - /** Config entry for VS Code (servers format, "servers" root key) */ - vscodeConfig: Record; + config: Record; // same for all editors; root key differs by editor }; type SkillInfo = { @@ -314,111 +311,31 @@ const MCP_SERVERS: McpServerDef[] = [ label: "Cloudinary Asset Management", description: "Browse, upload, and manage media assets", key: "cloudinary-asset-mgmt", - cursorConfig: { - command: "npx", - args: ["-y", "@cloudinary/asset-management", "mcp", "start"], - env: { - CLOUDINARY_CLOUD_NAME: "your_cloud_name", - CLOUDINARY_API_KEY: "your_api_key", - CLOUDINARY_API_SECRET: "your_api_secret", - }, - }, - vscodeConfig: { - type: "stdio", - command: "npx", - args: ["-y", "@cloudinary/asset-management", "mcp", "start"], - env: { - CLOUDINARY_CLOUD_NAME: "${env:CLOUDINARY_CLOUD_NAME}", - CLOUDINARY_API_KEY: "${env:CLOUDINARY_API_KEY}", - CLOUDINARY_API_SECRET: "${env:CLOUDINARY_API_SECRET}", - }, - }, + config: { url: "https://asset-management.mcp.cloudinary.com/mcp" }, }, { label: "Cloudinary Environment Config", description: "Configure upload presets, transformations, and settings", key: "cloudinary-env-config", - cursorConfig: { - command: "npx", - args: ["-y", "@cloudinary/environment-config", "mcp", "start"], - env: { - CLOUDINARY_CLOUD_NAME: "your_cloud_name", - CLOUDINARY_API_KEY: "your_api_key", - CLOUDINARY_API_SECRET: "your_api_secret", - }, - }, - vscodeConfig: { - type: "stdio", - command: "npx", - args: ["-y", "@cloudinary/environment-config", "mcp", "start"], - env: { - CLOUDINARY_CLOUD_NAME: "${env:CLOUDINARY_CLOUD_NAME}", - CLOUDINARY_API_KEY: "${env:CLOUDINARY_API_KEY}", - CLOUDINARY_API_SECRET: "${env:CLOUDINARY_API_SECRET}", - }, - }, + config: { url: "https://environment-config.mcp.cloudinary.com/mcp" }, }, { label: "Cloudinary Structured Metadata", description: "Manage structured metadata fields and values", key: "cloudinary-smd", - cursorConfig: { - command: "npx", - args: ["-y", "@cloudinary/structured-metadata", "mcp", "start"], - env: { - CLOUDINARY_CLOUD_NAME: "your_cloud_name", - CLOUDINARY_API_KEY: "your_api_key", - CLOUDINARY_API_SECRET: "your_api_secret", - }, - }, - vscodeConfig: { - type: "stdio", - command: "npx", - args: ["-y", "@cloudinary/structured-metadata", "mcp", "start"], - env: { - CLOUDINARY_CLOUD_NAME: "${env:CLOUDINARY_CLOUD_NAME}", - CLOUDINARY_API_KEY: "${env:CLOUDINARY_API_KEY}", - CLOUDINARY_API_SECRET: "${env:CLOUDINARY_API_SECRET}", - }, - }, + config: { url: "https://structured-metadata.mcp.cloudinary.com/mcp" }, }, { label: "Cloudinary Analysis", description: "AI-powered image and video analysis", key: "cloudinary-analysis", - cursorConfig: { - command: "npx", - args: ["-y", "@cloudinary/analysis", "mcp", "start"], - env: { - CLOUDINARY_CLOUD_NAME: "your_cloud_name", - CLOUDINARY_API_KEY: "your_api_key", - CLOUDINARY_API_SECRET: "your_api_secret", - }, - }, - vscodeConfig: { - type: "stdio", - command: "npx", - args: ["-y", "@cloudinary/analysis", "mcp", "start"], - env: { - CLOUDINARY_CLOUD_NAME: "${env:CLOUDINARY_CLOUD_NAME}", - CLOUDINARY_API_KEY: "${env:CLOUDINARY_API_KEY}", - CLOUDINARY_API_SECRET: "${env:CLOUDINARY_API_SECRET}", - }, - }, + config: { url: "https://analysis.mcp.cloudinary.com/sse" }, }, { label: "MediaFlows", description: "AI-powered media workflows and automation", key: "mediaflows", - cursorConfig: { - url: "https://mediaflows.mcp.cloudinary.com/v2/mcp", - headers: { - "cld-cloud-name": "your_cloud_name", - "cld-api-key": "your_api_key", - "cld-secret": "your_api_secret", - }, - }, - vscodeConfig: { + config: { url: "https://mediaflows.mcp.cloudinary.com/v2/mcp", headers: { "cld-cloud-name": "your_cloud_name", @@ -466,7 +383,7 @@ async function createMcpConfig( const servers = config[rootKey] as Record; for (const def of selectedDefs) { - servers[def.key] = isVscode ? def.vscodeConfig : def.cursorConfig; + servers[def.key] = def.config; } await ensureDir(vscode.Uri.joinPath(mcpUri, "..")); From 959080d9a0fb3efeac05af23e8831a4a0e7612a5 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Wed, 1 Apr 2026 13:23:42 +0100 Subject: [PATCH 39/72] feat: add skill and MCP status-detection helpers --- src/commands/configureAiTools.ts | 69 ++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/commands/configureAiTools.ts b/src/commands/configureAiTools.ts index 3801fe3..bfbf424 100644 --- a/src/commands/configureAiTools.ts +++ b/src/commands/configureAiTools.ts @@ -304,6 +304,75 @@ async function installForCopilot( } } +// ── Status detection ───────────────────────────────────────────────────────── + +/** + * Returns the set of skill dirNames already installed for the given IDE target. + * Errors reading individual paths are silently treated as "not installed". + */ +async function readInstalledSkillDirNames( + rootUri: vscode.Uri, + ideTargetLabel: string, + skills: SkillInfo[] +): Promise> { + const installed = new Set(); + + if (ideTargetLabel === "VS Code (Copilot)") { + try { + const uri = vscode.Uri.joinPath(rootUri, ".github/copilot-instructions.md"); + const bytes = await vscode.workspace.fs.readFile(uri); + const content = Buffer.from(bytes).toString("utf-8"); + for (const skill of skills) { + if (content.includes(`## ${skill.name}`)) { + installed.add(skill.dirName); + } + } + } catch { + // file not found — nothing installed + } + return installed; + } + + await Promise.all( + skills.map(async (skill) => { + try { + const checkPath = + ideTargetLabel === "Claude Code" + ? `.claude/skills/${skill.dirName}/SKILL.md` + : `.cursor/rules/${skill.dirName}.mdc`; + await vscode.workspace.fs.stat(vscode.Uri.joinPath(rootUri, checkPath)); + installed.add(skill.dirName); + } catch { + // not installed + } + }) + ); + return installed; +} + +/** + * Returns the set of server keys already present in the MCP config file. + * Returns an empty Set if the file doesn't exist or can't be parsed. + */ +async function readConfiguredMcpServerKeys( + rootUri: vscode.Uri, + mcpFilePath: string, + rootKey: string +): Promise> { + try { + const uri = vscode.Uri.joinPath(rootUri, mcpFilePath); + const bytes = await vscode.workspace.fs.readFile(uri); + const config = JSON.parse(Buffer.from(bytes).toString("utf-8")); + const servers = config[rootKey]; + if (servers && typeof servers === "object") { + return new Set(Object.keys(servers)); + } + } catch { + // file not found or invalid JSON + } + return new Set(); +} + // ── MCP Server definitions ──────────────────────────────────────────────────── const MCP_SERVERS: McpServerDef[] = [ From 2f9565d90ce75e9516c99919e0d0e28bb48a77e4 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Wed, 1 Apr 2026 15:42:43 +0100 Subject: [PATCH 40/72] feat: reorder skills flow and annotate QuickPicks with install status --- src/commands/configureAiTools.ts | 33 ++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/commands/configureAiTools.ts b/src/commands/configureAiTools.ts index bfbf424..af6f74c 100644 --- a/src/commands/configureAiTools.ts +++ b/src/commands/configureAiTools.ts @@ -423,8 +423,16 @@ async function createMcpConfig( mcpFilePath: string, createdFiles: string[] ): Promise { + const rootKey = editor === "vscode" ? "servers" : "mcpServers"; + const configuredKeys = await readConfiguredMcpServerKeys(rootUri, mcpFilePath, rootKey); + const selected = await vscode.window.showQuickPick( - MCP_SERVERS.map((s) => ({ label: s.label, description: s.description, picked: true })), + MCP_SERVERS.map((s) => ({ + label: s.label, + description: s.description, + detail: configuredKeys.has(s.key) ? "✓ already configured" : "Not configured", + picked: !configuredKeys.has(s.key), + })), { canPickMany: true, placeHolder: "Select MCP servers to configure" } ); if (!selected || selected.length === 0) { return; } @@ -434,8 +442,6 @@ async function createMcpConfig( .filter((s): s is McpServerDef => s !== undefined); const mcpUri = vscode.Uri.joinPath(rootUri, mcpFilePath); - const isVscode = editor === "vscode"; - const rootKey = isVscode ? "servers" : "mcpServers"; // Read and merge into existing config if present let config: Record = {}; @@ -500,13 +506,7 @@ function registerConfigureAiTools(context: vscode.ExtensionContext): void { return; } - const pickedSkills = await vscode.window.showQuickPick( - skills.map((s) => ({ label: s.name, description: s.description, picked: true })), - { canPickMany: true, placeHolder: "Select skills to install" } - ); - if (!pickedSkills || pickedSkills.length === 0) { return; } - - // IDE target — pre-select based on detected editor + // IDE target first — needed to check install status before showing skills const editor = detectEditor(); const ideOptions: vscode.QuickPickItem[] = [ { label: "Claude Code", description: "Install to .claude/skills/" }, @@ -530,6 +530,19 @@ function registerConfigureAiTools(context: vscode.ExtensionContext): void { }); if (!ideTarget) { return; } + const installedDirNames = await readInstalledSkillDirNames(rootUri, ideTarget.label, skills); + + const pickedSkills = await vscode.window.showQuickPick( + skills.map((s) => ({ + label: s.name, + description: s.description, + detail: installedDirNames.has(s.dirName) ? "✓ installed" : "Not installed", + picked: true, + })), + { canPickMany: true, placeHolder: "Select skills to install" } + ); + if (!pickedSkills || pickedSkills.length === 0) { return; } + for (const item of pickedSkills) { const skill = skills.find((s) => s.name === item.label); if (!skill) { continue; } From c23a9cfdabe22d297434b4fa527e97f68d9cadb1 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Wed, 1 Apr 2026 15:47:07 +0100 Subject: [PATCH 41/72] chore: document intentional picked:true asymmetry in skills picker Co-Authored-By: Claude Sonnet 4.6 --- src/commands/configureAiTools.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/configureAiTools.ts b/src/commands/configureAiTools.ts index af6f74c..03960c2 100644 --- a/src/commands/configureAiTools.ts +++ b/src/commands/configureAiTools.ts @@ -537,7 +537,7 @@ function registerConfigureAiTools(context: vscode.ExtensionContext): void { label: s.name, description: s.description, detail: installedDirNames.has(s.dirName) ? "✓ installed" : "Not installed", - picked: true, + picked: true, // always pre-selected; writeWithOverwriteCheck guards re-installs })), { canPickMany: true, placeHolder: "Select skills to install" } ); From 0a99b7bd9db3e8941c576dd47e55deddbb043532 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Wed, 1 Apr 2026 16:12:28 +0100 Subject: [PATCH 42/72] docs: add design spec for configure-ai-tools webview accordion panel Co-Authored-By: Claude Sonnet 4.6 --- ...04-01-configure-ai-tools-webview-design.md | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-01-configure-ai-tools-webview-design.md diff --git a/docs/superpowers/specs/2026-04-01-configure-ai-tools-webview-design.md b/docs/superpowers/specs/2026-04-01-configure-ai-tools-webview-design.md new file mode 100644 index 0000000..03cab6c --- /dev/null +++ b/docs/superpowers/specs/2026-04-01-configure-ai-tools-webview-design.md @@ -0,0 +1,158 @@ +# Configure AI Tools — Webview Panel Design Spec + +## Goal + +Replace the VS Code QuickPick flow for "Configure AI Tools" with an inline accordion panel that expands within the homescreen sidebar webview. Skills and MCP server configuration are presented as checklists in a single panel — no modal menus, no context switching. + +--- + +## Interaction Model + +The "Configure AI Tools" action row gains a chevron on the right. Clicking it toggles an accordion panel directly below the button. The rest of the homescreen (footer) is pushed down; the sidebar scrolls if needed. + +### Accordion States + +| State | Description | +|-------|-------------| +| **Loading** | Shown immediately on open. Extension fetches skill list from GitHub and reads workspace status in parallel, then posts `aiToolsData` to the webview. Spinner or skeleton rows. | +| **Ready** | Skills checklist + MCP servers checklist + Apply button visible. | +| **Applying** | Apply button becomes "Applying…" (disabled). Each row updates with ✓ or ✗ as it completes. | +| **Done** | All rows show final status. Accordion stays open so user can review results. | + +--- + +## Panel Content + +### Skills Sub-section + +Header: `Skills` + +A 3-button segmented control selects the IDE target: +- **Claude Code** (default unless `detectEditor()` returns `cursor` or `vscode`) +- **Cursor** +- **VS Code Copilot** + +Pre-selected based on `detectEditor()`. User can change before clicking Apply. + +Below the IDE selector, a flat checklist — one row per skill: + +``` +☑ cloudinary-docs ✓ installed +☑ cloudinary-react not installed +☑ cloudinary-transformations not installed +``` + +- Checkbox always starts **checked** regardless of install status (overwrite prompt in the extension is the safety net for already-installed skills) +- Status label is small and muted, shown to the right of the skill name + +### MCP Servers Sub-section + +Header: `MCP Servers` + +Same flat checklist pattern. Smart defaults: already-configured servers start **unchecked** to protect credentials from silent overwrite. + +``` +☐ Asset Management ✓ configured +☑ MediaFlows not configured +``` + +### Apply Button + +Full-width button at the bottom of the accordion, labelled **"Apply"**. + +- Disabled when no items are checked +- During apply: label becomes "Applying…", button disabled +- After apply: button label returns to "Apply" (panel stays open) + +--- + +## Data Flow + +### Open accordion + +1. User clicks the "Configure AI Tools" row +2. Webview toggles accordion open, shows loading state +3. Webview posts `{ command: "aiToolsExpanded" }` to extension +4. Extension in parallel: + - Fetches skill list from GitHub (`fetchSkillList()`) + - Reads workspace status (`readInstalledSkillDirNames`, `readConfiguredMcpServerKeys`) for all three IDE targets + - Detects editor (`detectEditor()`) +5. Extension posts to webview: +```json +{ + "command": "aiToolsData", + "skills": [ + { "name": "cloudinary-docs", "dirName": "cloudinary-docs", "description": "..." } + ], + "installedByIde": { + "Claude Code": ["cloudinary-docs"], + "Cursor": [], + "VS Code (Copilot)": [] + }, + "mcpServers": [ + { "key": "cloudinary-asset-mgmt", "label": "Asset Management", "description": "..." } + ], + "configuredMcpKeys": ["cloudinary-asset-mgmt"], + "detectedIde": "Claude Code" +} +``` +6. Webview renders the checklist from this data + +### Apply + +1. User clicks Apply +2. Webview posts: +```json +{ + "command": "installAiTools", + "skills": ["cloudinary-react", "cloudinary-transformations"], + "ideTarget": "Claude Code", + "mcpServers": ["mediaflows"] +} +``` +3. Extension processes each item, posting progress after each: +```json +{ "command": "aiToolsProgress", "item": "cloudinary-react", "status": "done" } +{ "command": "aiToolsProgress", "item": "mediaflows", "status": "done" } +``` +4. Extension posts final result: +```json +{ "command": "aiToolsResult", "errors": [] } +``` +5. Webview updates row statuses; shows error rows if any + +### Error on skill fetch + +If GitHub fetch fails, extension posts: +```json +{ "command": "aiToolsData", "error": "Failed to fetch skills: " } +``` +Webview shows the error inline in the accordion. + +--- + +## File Map + +| Action | File | What changes | +|--------|------|-------------| +| Modify | `src/webview/homescreenView.ts` | Add accordion HTML; handle `aiToolsExpanded` / `installAiTools` messages; send `aiToolsData` / `aiToolsProgress` / `aiToolsResult` | +| Modify | `src/webview/client/homescreen.ts` | Accordion toggle logic; render panel from `aiToolsData`; wire Apply; handle progress/result messages | +| Modify | `src/commands/configureAiTools.ts` | Extract `installSkill()` and `installMcpServers()` as exported functions; the `cloudinary.configureAiTools` command becomes a thin wrapper (or is removed if the button is the only entry point) | + +--- + +## Styling Constraints + +- Follow existing homescreen CSS patterns (`--vscode-*` CSS variables only, no hardcoded colours) +- Accordion panel uses the same `hs-action` row sizing and typography +- Segmented control uses existing button/border styles +- Checklist rows are compact: ~28–30px per row to fit within narrow sidebar +- Apply button matches `.hs-setup-banner-btn` style but full-width + +--- + +## Out of Scope + +- The `cloudinary.configureAiTools` VS Code command (palette entry) continues to exist as a thin wrapper calling the same install logic — no regression for keyboard users +- No animations on accordion open/close beyond CSS `max-height` transition (keep it simple) +- No per-section independent Apply buttons — one Apply commits everything From d640a96381b3d43320a487d2f823a394fa13b2ff Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Wed, 1 Apr 2026 16:17:19 +0100 Subject: [PATCH 43/72] design: add configure-ai-tools accordion UI prototype All states: loading skeleton, ready (IDE selector + checklists), applying (per-item progress ticks), done. Fully interactive. Co-Authored-By: Claude Sonnet 4.6 --- .../design/ai-tools-accordion-prototype.html | 622 ++++++++++++++++++ 1 file changed, 622 insertions(+) create mode 100644 docs/superpowers/design/ai-tools-accordion-prototype.html diff --git a/docs/superpowers/design/ai-tools-accordion-prototype.html b/docs/superpowers/design/ai-tools-accordion-prototype.html new file mode 100644 index 0000000..0935fb3 --- /dev/null +++ b/docs/superpowers/design/ai-tools-accordion-prototype.html @@ -0,0 +1,622 @@ + + + + + +Configure AI Tools — Accordion Prototype + + + + + + + + +
+

Preview States

+ + + + +
+ +
+

In the real extension, state transitions are driven by messages between the webview and extension host.

+
+ + + + From fbf0cf2a691622a57042d15efaa984c8e097828e Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Wed, 1 Apr 2026 16:30:07 +0100 Subject: [PATCH 44/72] docs: add webview accordion implementation plan --- .../2026-04-01-configure-ai-tools-webview.md | 1778 +++++++++++++++++ 1 file changed, 1778 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-01-configure-ai-tools-webview.md diff --git a/docs/superpowers/plans/2026-04-01-configure-ai-tools-webview.md b/docs/superpowers/plans/2026-04-01-configure-ai-tools-webview.md new file mode 100644 index 0000000..82a0626 --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-configure-ai-tools-webview.md @@ -0,0 +1,1778 @@ +# Configure AI Tools Webview Accordion — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the "Configure AI Tools" VS Code QuickPick flow with an inline accordion panel inside the homescreen sidebar webview that lets users select and install skills and MCP servers without leaving the sidebar. + +**Architecture:** Extract all business logic from `src/commands/configureAiTools.ts` into a new `src/aiToolsService.ts` module that is importable by both the command registration and the webview provider. The homescreen webview provider gains two new message handlers (`aiToolsExpanded`, `installAiTools`) that call the service, stream progress events back via `postMessage`, and return a final result. The client-side TypeScript in `src/webview/client/homescreen.ts` is fully rewritten to drive the accordion state machine. + +**Tech Stack:** TypeScript, VS Code Extension API (`vscode.WebviewView`, `vscode.workspace.fs`, `vscode.authentication`), esbuild (bundler), Mocha + `@vscode/test-electron` (tests) + +--- + +## File Map + +| Action | Path | Responsibility | +|--------|------|----------------| +| **Create** | `src/aiToolsService.ts` | All types, constants, GitHub fetch, install, and read helpers; new `installMcpServers` function | +| **Modify** | `src/commands/configureAiTools.ts` | Import from `../aiToolsService`; keep `createMcpConfig` (simplified) and `registerConfigureAiTools` | +| **Modify** | `src/webview/homescreenView.ts` | Add accordion HTML/CSS; add `_cachedSkills`, `_handleAiToolsExpanded`, `_handleInstallAiTools`; update message switch | +| **Modify** | `src/webview/client/homescreen.ts` | Full rewrite: accordion state machine, IDE pill slider, message dispatch and handling | + +--- + +## Task 1: Create `src/aiToolsService.ts` and slim down `src/commands/configureAiTools.ts` + +**Files:** +- Create: `src/aiToolsService.ts` +- Modify: `src/commands/configureAiTools.ts` + +### Step 1 — Create `src/aiToolsService.ts` + +Create the file `/Users/nickbradley/dev/cloudinary-vscode/src/aiToolsService.ts` with the following content: + +```typescript +import * as vscode from "vscode"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type EditorType = "cursor" | "vscode" | "windsurf" | "antigravity" | "unknown"; + +export type McpServerDef = { + label: string; + description: string; + key: string; + config: Record; +}; + +export type SkillInfo = { + name: string; + description: string; + dirName: string; +}; + +type GitHubEntry = { + name: string; + type: "file" | "dir"; +}; + +type GitHubFile = { + content: string; // base64-encoded + encoding: string; +}; + +// ── Editor detection ────────────────────────────────────────────────────────── + +export function detectEditor(): EditorType { + const uriScheme = vscode.env.uriScheme.toLowerCase(); + if (uriScheme === "cursor") { return "cursor"; } + if (uriScheme === "windsurf") { return "windsurf"; } + if (uriScheme === "antigravity" || uriScheme === "gemini") { return "antigravity"; } + if (uriScheme === "vscode" || uriScheme === "vscode-insiders") { return "vscode"; } + const appName = vscode.env.appName.toLowerCase(); + if (appName.includes("cursor")) { return "cursor"; } + if (appName.includes("windsurf")) { return "windsurf"; } + if (appName.includes("antigravity") || appName.includes("gemini")) { return "antigravity"; } + if (appName.includes("visual studio code") || appName.includes("vscode")) { return "vscode"; } + return "unknown"; +} + +export function getMcpFilePath(editor: EditorType): string { + switch (editor) { + case "cursor": return ".cursor/mcp.json"; + case "windsurf": return ".windsurf/mcp.json"; + case "antigravity": return ".agent/mcp_config.json"; + case "vscode": + default: return ".vscode/mcp.json"; + } +} + +// ── GitHub API helpers ──────────────────────────────────────────────────────── + +const SKILLS_BASE = "https://api.github.com/repos/cloudinary-devs/skills/contents"; + +export async function githubFetchJson(url: string): Promise { + const baseHeaders: Record = { Accept: "application/vnd.github+json" }; + + let response = await fetch(url, { headers: baseHeaders }); + + if (!response.ok && [401, 403, 404].includes(response.status)) { + try { + const session = await vscode.authentication.getSession("github", ["repo"], { createIfNone: true }); + if (session) { + response = await fetch(url, { + headers: { ...baseHeaders, Authorization: `Bearer ${session.accessToken}` }, + }); + } + } catch { + // auth declined or unavailable — fall through with original error + } + } + + if (!response.ok) { + throw new Error(`GitHub API ${response.status}: ${url}`); + } + return response.json() as Promise; +} + +export function decodeBase64(encoded: string): string { + return Buffer.from(encoded.replace(/\n/g, ""), "base64").toString("utf-8"); +} + +// ── Frontmatter helpers ─────────────────────────────────────────────────────── + +export function parseFrontmatter(content: string): Record { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) { return {}; } + const result: Record = {}; + for (const line of match[1].split("\n")) { + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) { continue; } + const key = line.slice(0, colonIdx).trim(); + const value = line.slice(colonIdx + 1).trim(); + if (key) { result[key] = value; } + } + return result; +} + +export function getBodyAfterFrontmatter(content: string): string { + return content.replace(/^---\n[\s\S]*?\n---\n?/, "").trim(); +} + +export function toMdcContent(content: string): string { + return content.replace(/^(---\n)([\s\S]*?)(\n---)/, (_, open, body, close) => { + const filtered = body + .split("\n") + .filter((line: string) => !line.startsWith("name:")) + .join("\n"); + return `${open}${filtered}${close}`; + }); +} + +// ── Skill fetching ──────────────────────────────────────────────────────────── + +export async function fetchSkillList(): Promise { + const entries = await githubFetchJson(`${SKILLS_BASE}/skills`); + const dirs = entries.filter((e) => e.type === "dir"); + + const results = await Promise.all( + dirs.map(async (dir): Promise => { + try { + const file = await githubFetchJson( + `${SKILLS_BASE}/skills/${dir.name}/SKILL.md` + ); + const content = decodeBase64(file.content); + const fm = parseFrontmatter(content); + return { name: fm.name || dir.name, description: fm.description || "", dirName: dir.name }; + } catch { + return null; + } + }) + ); + + return results.filter((s): s is SkillInfo => s !== null); +} + +export async function fetchSkillContent(skillName: string): Promise { + const file = await githubFetchJson( + `${SKILLS_BASE}/skills/${skillName}/SKILL.md` + ); + return decodeBase64(file.content); +} + +export async function fetchReferenceFiles( + skillName: string +): Promise> { + let entries: GitHubEntry[]; + try { + entries = await githubFetchJson( + `${SKILLS_BASE}/skills/${skillName}/references` + ); + } catch { + return []; + } + + const results = await Promise.all( + entries + .filter((e) => e.type === "file") + .map(async (e) => { + try { + const file = await githubFetchJson( + `${SKILLS_BASE}/skills/${skillName}/references/${e.name}` + ); + return { name: e.name, content: decodeBase64(file.content) }; + } catch { + return null; + } + }) + ); + return results.filter((f): f is { name: string; content: string } => f !== null); +} + +// ── Filesystem helpers ──────────────────────────────────────────────────────── + +export async function ensureDir(uri: vscode.Uri): Promise { + try { await vscode.workspace.fs.createDirectory(uri); } catch { /* already exists */ } +} + +export async function writeWithOverwriteCheck( + uri: vscode.Uri, + content: string, + label: string +): Promise { + try { + await vscode.workspace.fs.stat(uri); + const answer = await vscode.window.showWarningMessage( + `${label} already exists. Overwrite?`, + "Yes", + "No" + ); + if (answer !== "Yes") { return false; } + } catch { + // file doesn't exist — proceed + } + await ensureDir(vscode.Uri.joinPath(uri, "..")); + await vscode.workspace.fs.writeFile(uri, Buffer.from(content, "utf-8")); + return true; +} + +// ── Skill installation — per IDE ────────────────────────────────────────────── + +export async function installForClaudeCode( + rootUri: vscode.Uri, + skillName: string, + skillContent: string, + createdFiles: string[], + errors: string[] +): Promise { + const skillFile = vscode.Uri.joinPath( + rootUri, `.claude/skills/${skillName}/SKILL.md` + ); + const written = await writeWithOverwriteCheck( + skillFile, skillContent, `${skillName}/SKILL.md` + ); + if (!written) { return; } + createdFiles.push(`.claude/skills/${skillName}/SKILL.md`); + + let refs: Array<{ name: string; content: string }>; + try { + refs = await fetchReferenceFiles(skillName); + } catch (err: any) { + errors.push(`${skillName} references: ${err.message}`); + return; + } + + for (const ref of refs) { + try { + const refUri = vscode.Uri.joinPath( + rootUri, `.claude/skills/${skillName}/references/${ref.name}` + ); + await ensureDir(vscode.Uri.joinPath(refUri, "..")); + await vscode.workspace.fs.writeFile(refUri, Buffer.from(ref.content, "utf-8")); + createdFiles.push(`.claude/skills/${skillName}/references/${ref.name}`); + } catch (err: any) { + errors.push(`${skillName}/references/${ref.name}: ${err.message}`); + } + } +} + +export async function installForCursor( + rootUri: vscode.Uri, + skillName: string, + skillContent: string, + createdFiles: string[] +): Promise { + const mdcUri = vscode.Uri.joinPath(rootUri, `.cursor/rules/${skillName}.mdc`); + const written = await writeWithOverwriteCheck( + mdcUri, toMdcContent(skillContent), `${skillName}.mdc` + ); + if (written) { createdFiles.push(`.cursor/rules/${skillName}.mdc`); } +} + +export async function installForCopilot( + rootUri: vscode.Uri, + skillName: string, + skillContent: string, + createdFiles: string[] +): Promise { + const instructionsUri = vscode.Uri.joinPath( + rootUri, ".github/copilot-instructions.md" + ); + await ensureDir(vscode.Uri.joinPath(rootUri, ".github")); + + let existing = ""; + try { + const bytes = await vscode.workspace.fs.readFile(instructionsUri); + existing = Buffer.from(bytes).toString("utf-8"); + } catch { + // new file + } + + if (existing.includes(`## ${skillName}`)) { + if (!createdFiles.includes(".github/copilot-instructions.md")) { + createdFiles.push(".github/copilot-instructions.md"); + } + return; + } + + const body = getBodyAfterFrontmatter(skillContent); + const section = `## ${skillName}\n\n${body}\n`; + const separator = existing.length > 0 ? "\n" : ""; + + await vscode.workspace.fs.writeFile( + instructionsUri, + Buffer.from(existing + separator + section, "utf-8") + ); + + if (!createdFiles.includes(".github/copilot-instructions.md")) { + createdFiles.push(".github/copilot-instructions.md"); + } +} + +// ── Status detection ────────────────────────────────────────────────────────── + +export async function readInstalledSkillDirNames( + rootUri: vscode.Uri, + ideTargetLabel: string, + skills: SkillInfo[] +): Promise> { + const installed = new Set(); + + if (ideTargetLabel === "VS Code (Copilot)") { + try { + const uri = vscode.Uri.joinPath(rootUri, ".github/copilot-instructions.md"); + const bytes = await vscode.workspace.fs.readFile(uri); + const content = Buffer.from(bytes).toString("utf-8"); + for (const skill of skills) { + if (content.includes(`## ${skill.name}`)) { + installed.add(skill.dirName); + } + } + } catch { + // file not found — nothing installed + } + return installed; + } + + await Promise.all( + skills.map(async (skill) => { + try { + const checkPath = + ideTargetLabel === "Claude Code" + ? `.claude/skills/${skill.dirName}/SKILL.md` + : `.cursor/rules/${skill.dirName}.mdc`; + await vscode.workspace.fs.stat(vscode.Uri.joinPath(rootUri, checkPath)); + installed.add(skill.dirName); + } catch { + // not installed + } + }) + ); + return installed; +} + +export async function readConfiguredMcpServerKeys( + rootUri: vscode.Uri, + mcpFilePath: string, + rootKey: string +): Promise> { + try { + const uri = vscode.Uri.joinPath(rootUri, mcpFilePath); + const bytes = await vscode.workspace.fs.readFile(uri); + const config = JSON.parse(Buffer.from(bytes).toString("utf-8")); + const servers = config[rootKey]; + if (servers && typeof servers === "object") { + return new Set(Object.keys(servers)); + } + } catch { + // file not found or invalid JSON + } + return new Set(); +} + +// ── MCP Server definitions ──────────────────────────────────────────────────── + +export const MCP_SERVERS: McpServerDef[] = [ + { + label: "Cloudinary Asset Management", + description: "Browse, upload, and manage media assets", + key: "cloudinary-asset-mgmt", + config: { url: "https://asset-management.mcp.cloudinary.com/mcp" }, + }, + { + label: "Cloudinary Environment Config", + description: "Configure upload presets, transformations, and settings", + key: "cloudinary-env-config", + config: { url: "https://environment-config.mcp.cloudinary.com/mcp" }, + }, + { + label: "Cloudinary Structured Metadata", + description: "Manage structured metadata fields and values", + key: "cloudinary-smd", + config: { url: "https://structured-metadata.mcp.cloudinary.com/mcp" }, + }, + { + label: "Cloudinary Analysis", + description: "AI-powered image and video analysis", + key: "cloudinary-analysis", + config: { url: "https://analysis.mcp.cloudinary.com/sse" }, + }, + { + label: "MediaFlows", + description: "AI-powered media workflows and automation", + key: "mediaflows", + config: { + url: "https://mediaflows.mcp.cloudinary.com/v2/mcp", + headers: { + "cld-cloud-name": "your_cloud_name", + "cld-api-key": "your_api_key", + "cld-secret": "your_api_secret", + }, + }, + }, +]; + +// ── MCP installation helper ─────────────────────────────────────────────────── + +export async function installMcpServers( + rootUri: vscode.Uri, + editor: EditorType, + selectedKeys: string[], + createdFiles: string[] +): Promise { + const mcpFilePath = getMcpFilePath(editor); + const isVscode = editor === "vscode"; + const rootKey = isVscode ? "servers" : "mcpServers"; + const mcpUri = vscode.Uri.joinPath(rootUri, mcpFilePath); + let config: Record = {}; + try { + const bytes = await vscode.workspace.fs.readFile(mcpUri); + config = JSON.parse(Buffer.from(bytes).toString("utf-8")); + } catch { + // new file + } + if (!config[rootKey] || typeof config[rootKey] !== "object") { + config[rootKey] = {}; + } + const servers = config[rootKey] as Record; + for (const key of selectedKeys) { + const def = MCP_SERVERS.find((s) => s.key === key); + if (def) { + servers[def.key] = def.config; + } + } + await ensureDir(vscode.Uri.joinPath(mcpUri, "..")); + await vscode.workspace.fs.writeFile( + mcpUri, + Buffer.from(JSON.stringify(config, null, 2), "utf-8") + ); + if (!createdFiles.includes(mcpFilePath)) { + createdFiles.push(mcpFilePath); + } +} +``` + +### Step 2 — Overwrite `src/commands/configureAiTools.ts` to import from the service + +Replace the entire file `/Users/nickbradley/dev/cloudinary-vscode/src/commands/configureAiTools.ts` with: + +```typescript +import * as vscode from "vscode"; +import { + EditorType, + McpServerDef, + SkillInfo, + MCP_SERVERS, + detectEditor, + getMcpFilePath, + fetchSkillList, + fetchSkillContent, + installForClaudeCode, + installForCursor, + installForCopilot, + readInstalledSkillDirNames, + readConfiguredMcpServerKeys, + installMcpServers, + ensureDir, +} from "../aiToolsService"; + +// ── MCP Config (QuickPick flow) ─────────────────────────────────────────────── + +async function createMcpConfig( + rootUri: vscode.Uri, + editor: EditorType, + mcpFilePath: string, + createdFiles: string[] +): Promise { + const rootKey = editor === "vscode" ? "servers" : "mcpServers"; + const configuredKeys = await readConfiguredMcpServerKeys(rootUri, mcpFilePath, rootKey); + + const selected = await vscode.window.showQuickPick( + MCP_SERVERS.map((s) => ({ + label: s.label, + description: s.description, + detail: configuredKeys.has(s.key) ? "✓ already configured" : "Not configured", + picked: !configuredKeys.has(s.key), + })), + { canPickMany: true, placeHolder: "Select MCP servers to configure" } + ); + if (!selected || selected.length === 0) { return; } + + const selectedKeys = selected + .map((item) => MCP_SERVERS.find((s) => s.label === item.label)) + .filter((s): s is McpServerDef => s !== undefined) + .map((s) => s.key); + + await installMcpServers(rootUri, editor, selectedKeys, createdFiles); +} + +// ── Command registration ────────────────────────────────────────────────────── + +function registerConfigureAiTools(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.commands.registerCommand("cloudinary.configureAiTools", async () => { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + vscode.window.showErrorMessage("Please open a workspace folder first."); + return; + } + const rootUri = workspaceFolders[0].uri; + + // ── Step 1: what to configure ────────────────────────────────────────── + const options = await vscode.window.showQuickPick( + [ + { label: "Skills", description: "Install Cloudinary agent skills", picked: true }, + { label: "MCP Config", description: "Add MCP server configuration file", picked: true }, + ], + { canPickMany: true, placeHolder: "Select what to configure" } + ); + if (!options || options.length === 0) { return; } + + const createdFiles: string[] = []; + const errors: string[] = []; + + // ── Step 2: skills flow ──────────────────────────────────────────────── + if (options.some((o) => o.label === "Skills")) { + let skills: SkillInfo[]; + try { + skills = await fetchSkillList(); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to fetch skills: ${err.message}`); + return; + } + + const editor = detectEditor(); + const ideOptions: vscode.QuickPickItem[] = [ + { label: "Claude Code", description: "Install to .claude/skills/" }, + { label: "Cursor", description: "Install to .cursor/rules/" }, + { label: "VS Code (Copilot)", description: "Append to .github/copilot-instructions.md" }, + ]; + const defaultLabel = + editor === "cursor" ? "Cursor" : + editor === "vscode" ? "VS Code (Copilot)" : + "Claude Code"; + + const qp = vscode.window.createQuickPick(); + qp.items = ideOptions; + qp.activeItems = ideOptions.filter((o) => o.label === defaultLabel); + qp.placeholder = "Select AI tool to install skills for"; + + const ideTarget = await new Promise((resolve) => { + qp.onDidAccept(() => { resolve(qp.activeItems[0]); qp.dispose(); }); + qp.onDidHide(() => { resolve(undefined); qp.dispose(); }); + qp.show(); + }); + if (!ideTarget) { return; } + + const installedDirNames = await readInstalledSkillDirNames(rootUri, ideTarget.label, skills); + + const pickedSkills = await vscode.window.showQuickPick( + skills.map((s) => ({ + label: s.name, + description: s.description, + detail: installedDirNames.has(s.dirName) ? "✓ installed" : "Not installed", + picked: true, + })), + { canPickMany: true, placeHolder: "Select skills to install" } + ); + if (!pickedSkills || pickedSkills.length === 0) { return; } + + for (const item of pickedSkills) { + const skill = skills.find((s) => s.name === item.label); + if (!skill) { continue; } + let content: string; + try { + content = await fetchSkillContent(skill.dirName); + } catch (err: any) { + errors.push(`${skill.dirName}: ${err.message}`); + continue; + } + + if (ideTarget.label === "Claude Code") { + await installForClaudeCode(rootUri, skill.dirName, content, createdFiles, errors); + } else if (ideTarget.label === "Cursor") { + try { + await installForCursor(rootUri, skill.dirName, content, createdFiles); + } catch (err) { + errors.push(`${skill.dirName}: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + try { + await installForCopilot(rootUri, skill.name, content, createdFiles); + } catch (err) { + errors.push(`${skill.name}: ${err instanceof Error ? err.message : String(err)}`); + } + } + } + } + + // ── Step 3: MCP config flow ──────────────────────────────────────────── + if (options.some((o) => o.label === "MCP Config")) { + const editor = detectEditor(); + await createMcpConfig(rootUri, editor, getMcpFilePath(editor), createdFiles); + } + + // ── Step 4: feedback ─────────────────────────────────────────────────── + if (errors.length > 0) { + vscode.window.showWarningMessage( + `Some files could not be downloaded: ${errors.join(", ")}` + ); + } + + if (createdFiles.length > 0) { + const action = await vscode.window.showInformationMessage( + `✅ Configured AI tools: ${createdFiles.join(", ")}`, + "Open File" + ); + if (action === "Open File") { + const doc = await vscode.workspace.openTextDocument( + vscode.Uri.joinPath(rootUri, createdFiles[0]) + ); + vscode.window.showTextDocument(doc); + } + } else if (errors.length === 0) { + vscode.window.showInformationMessage("No files were written — all targets already exist."); + } + }) + ); +} + +export default registerConfigureAiTools; +``` + +### Step 3 — Verify types compile + +```bash +cd /Users/nickbradley/dev/cloudinary-vscode && npm run check-types +``` + +Expected: no errors. If you see "Cannot find module '../aiToolsService'" it means the file wasn't saved to the right path. Double-check `src/aiToolsService.ts` exists. + +### Step 4 — Commit + +```bash +cd /Users/nickbradley/dev/cloudinary-vscode && git add src/aiToolsService.ts src/commands/configureAiTools.ts && git commit -m "refactor: extract AI tools business logic into aiToolsService" +``` + +--- + +## Task 2: Add accordion HTML and CSS to `src/webview/homescreenView.ts` + +**Files:** +- Modify: `src/webview/homescreenView.ts` + +This task makes two changes to `homescreenView.ts`: +1. Adds accordion CSS to the `` tag. Insert the following CSS **before** that closing tag: + +```css + /* ── AI Tools accordion ── */ + #hs-btn-ai-tools { user-select: none; } + #hs-btn-ai-tools.expanded { + background: var(--vscode-list-hoverBackground); + border-radius: 7px 7px 0 0; + } + + .hs-ai-panel { + overflow: hidden; + max-height: 0; + transition: max-height 0.28s cubic-bezier(0.4, 0, 0.2, 1); + border-radius: 0 0 7px 7px; + background: rgba(255,255,255,0.02); + border-top: 1px solid transparent; + } + .hs-ai-panel.open { + max-height: 520px; + border-top-color: var(--vscode-panel-border, rgba(128,128,128,0.14)); + } + .hs-ai-panel-inner { + padding: 10px 10px 12px; + display: flex; + flex-direction: column; + gap: 10px; + } + + /* Loading skeletons */ + .hs-ai-loading { display: flex; flex-direction: column; gap: 6px; } + .hs-skeleton { + height: 22px; + border-radius: 4px; + background: linear-gradient( + 90deg, + rgba(255,255,255,0.04) 0%, + rgba(255,255,255,0.09) 50%, + rgba(255,255,255,0.04) 100% + ); + background-size: 200% 100%; + animation: shimmer 1.4s ease infinite; + } + .hs-skeleton--short { width: 55%; } + .hs-skeleton--label { height: 10px; width: 38%; margin-bottom: 4px; } + @keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } + } + + /* Section headers */ + .hs-ai-section-head { + display: flex; + align-items: center; + gap: 7px; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.9px; + text-transform: uppercase; + color: var(--vscode-descriptionForeground); + opacity: 0.8; + margin-bottom: 5px; + } + .hs-ai-section-head::after { + content: ''; + flex: 1; + height: 1px; + background: var(--vscode-panel-border, rgba(128,128,128,0.14)); + } + + /* IDE segmented control */ + .hs-ai-ide { + position: relative; + display: flex; + background: rgba(255,255,255,0.04); + border: 1px solid var(--vscode-panel-border, rgba(128,128,128,0.14)); + border-radius: 5px; + padding: 2px; + margin-bottom: 7px; + } + .hs-ai-ide-pill { + position: absolute; + top: 2px; + height: calc(100% - 4px); + background: rgba(52,72,197,0.35); + border: 1px solid rgba(52,72,197,0.5); + border-radius: 3px; + transition: + left 0.15s cubic-bezier(0.4,0,0.2,1), + width 0.15s cubic-bezier(0.4,0,0.2,1); + pointer-events: none; + } + .hs-ai-ide-btn { + flex: 1; + padding: 3px 4px; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.2px; + text-align: center; + text-transform: uppercase; + background: none; + border: none; + border-radius: 3px; + color: var(--vscode-descriptionForeground); + cursor: pointer; + font-family: var(--vscode-font-family); + position: relative; + z-index: 1; + transition: color 0.15s; + white-space: nowrap; + } + .hs-ai-ide-btn.active { color: var(--vscode-foreground); } + .hs-ai-ide-btn:hover:not(.active) { color: var(--vscode-foreground); opacity: 0.7; } + + /* Checklist items */ + .hs-ai-item { + display: flex; + align-items: center; + gap: 7px; + padding: 3px 4px 3px 2px; + border-radius: 4px; + transition: background 0.1s; + cursor: pointer; + animation: hs-row-in 0.18s ease both; + } + .hs-ai-item:hover { background: var(--vscode-list-hoverBackground); } + .hs-ai-item:nth-child(1) { animation-delay: .05s; } + .hs-ai-item:nth-child(2) { animation-delay: .09s; } + .hs-ai-item:nth-child(3) { animation-delay: .13s; } + .hs-ai-item:nth-child(4) { animation-delay: .17s; } + .hs-ai-item:nth-child(5) { animation-delay: .21s; } + @keyframes hs-row-in { + from { opacity: 0; transform: translateX(-4px); } + to { opacity: 1; transform: translateX(0); } + } + + /* Custom checkbox */ + .hs-ai-cb { + appearance: none; + -webkit-appearance: none; + width: 12px; + height: 12px; + flex-shrink: 0; + border: 1.5px solid var(--vscode-checkbox-border); + border-radius: 2px; + background: var(--vscode-checkbox-background); + cursor: pointer; + position: relative; + transition: border-color 0.1s, background 0.1s; + } + .hs-ai-cb:checked { + background: var(--vscode-button-background); + border-color: var(--vscode-button-background); + } + .hs-ai-cb:checked::after { + content: ''; + position: absolute; + left: 2px; top: -1px; + width: 5px; height: 8px; + border: 1.5px solid var(--vscode-button-foreground); + border-top: none; + border-left: none; + transform: rotate(45deg); + } + .hs-ai-cb:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; + } + + .hs-ai-item-name { + flex: 1; + font-size: 11px; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + } + + /* Status indicator */ + .hs-ai-item-status { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 4px; + font-size: 9.5px; + color: var(--vscode-descriptionForeground); + white-space: nowrap; + } + .hs-ai-item-status::before { + content: ''; + display: inline-block; + width: 5px; + height: 5px; + border-radius: 1px; + flex-shrink: 0; + } + .hs-ai-item-status--ok::before { background: #4ade80; } + .hs-ai-item-status--none::before { background: rgba(255,255,255,0.15); } + + /* Progress tick */ + .hs-ai-item-tick { + flex-shrink: 0; + width: 13px; + height: 13px; + display: flex; + align-items: center; + justify-content: center; + font-size: 9px; + animation: tick-in 0.2s cubic-bezier(0.34,1.56,0.64,1) both; + } + @keyframes tick-in { + from { opacity: 0; transform: scale(0); } + to { opacity: 1; transform: scale(1); } + } + .hs-ai-item-tick--ok { color: #4ade80; } + .hs-ai-item-tick--err { color: var(--vscode-errorForeground); } + + /* Apply button */ + .hs-ai-apply { + width: 100%; + padding: 6px 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.3px; + color: var(--vscode-button-foreground); + background: var(--vscode-button-background); + border: none; + border-radius: 5px; + cursor: pointer; + font-family: var(--vscode-font-family); + transition: opacity 0.12s; + position: relative; + overflow: hidden; + margin-top: 2px; + } + .hs-ai-apply::after { + content: ''; + position: absolute; + inset: 0; + background: rgba(255,255,255,0); + transition: background 0.12s; + } + .hs-ai-apply:hover::after { background: rgba(255,255,255,0.08); } + .hs-ai-apply:disabled { opacity: 0.35; cursor: default; } + .hs-ai-apply:disabled::after { background: none; } + .hs-ai-apply:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; + } + + /* Error banner */ + .hs-ai-error { + font-size: 10.5px; + color: var(--vscode-errorForeground); + padding: 5px 7px; + border-radius: 4px; + background: rgba(241,76,76,0.08); + border: 1px solid rgba(241,76,76,0.2); + } + + .hidden { display: none !important; } +``` + +### Step 2 — Replace the AI Tools button HTML + +In `_getBodyContent()`, find the existing button: + +```html + +``` + +Replace that entire button with: + +```html + + + +
+ + +
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + +
+``` + +### Step 3 — Verify types compile + +```bash +cd /Users/nickbradley/dev/cloudinary-vscode && npm run check-types +``` + +Expected: no errors. + +### Step 4 — Commit + +```bash +cd /Users/nickbradley/dev/cloudinary-vscode && git add src/webview/homescreenView.ts && git commit -m "feat: add accordion HTML and CSS to homescreen sidebar" +``` + +--- + +## Task 3: Add message handlers to `src/webview/homescreenView.ts` + +**Files:** +- Modify: `src/webview/homescreenView.ts` + +This task wires up the extension-side message handling: receiving `aiToolsExpanded` and `installAiTools` from the webview, calling service functions, and posting responses back. + +### Step 1 — Add the import for `aiToolsService` + +At the top of `src/webview/homescreenView.ts`, after the existing imports, add: + +```typescript +import { + SkillInfo, + McpServerDef, + EditorType, + MCP_SERVERS, + detectEditor, + getMcpFilePath, + fetchSkillList, + fetchSkillContent, + readInstalledSkillDirNames, + readConfiguredMcpServerKeys, + installForClaudeCode, + installForCursor, + installForCopilot, + installMcpServers, +} from "../aiToolsService"; +``` + +### Step 2 — Add `_cachedSkills` property to the class + +Inside `HomescreenViewProvider`, after the `private _webviewView` declaration, add: + +```typescript + private _cachedSkills: SkillInfo[] | undefined; +``` + +### Step 3 — Add `_handleAiToolsExpanded` method + +Add this method to `HomescreenViewProvider` (before the closing `}`): + +```typescript + private async _handleAiToolsExpanded(): Promise { + const view = this._webviewView; + if (!view) { return; } + + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + view.webview.postMessage({ + command: "aiToolsData", + error: "Please open a workspace folder first.", + }); + return; + } + const rootUri = workspaceFolders[0].uri; + + try { + // Fetch skills once; cache for subsequent opens + if (!this._cachedSkills) { + this._cachedSkills = await fetchSkillList(); + } + const skills = this._cachedSkills; + + const ideLabels: string[] = ["Claude Code", "Cursor", "VS Code (Copilot)"]; + + // Pre-compute installed status for all 3 IDEs + const installedByIde: Record = {}; + await Promise.all( + ideLabels.map(async (label) => { + const installedSet = await readInstalledSkillDirNames(rootUri, label, skills); + installedByIde[label] = [...installedSet]; + }) + ); + + // MCP servers — use detected editor for the config file path + const editor = detectEditor(); + const mcpFilePath = getMcpFilePath(editor); + const rootKey = editor === "vscode" ? "servers" : "mcpServers"; + const configuredMcpSet = await readConfiguredMcpServerKeys(rootUri, mcpFilePath, rootKey); + + const detectedIde = + editor === "cursor" ? "Cursor" : + editor === "vscode" ? "VS Code (Copilot)" : + "Claude Code"; + + view.webview.postMessage({ + command: "aiToolsData", + skills: skills.map((s) => ({ name: s.name, description: s.description, dirName: s.dirName })), + installedByIde, + mcpServers: MCP_SERVERS.map((s) => ({ key: s.key, label: s.label, description: s.description })), + configuredMcpKeys: [...configuredMcpSet], + detectedIde, + }); + } catch (err: any) { + view.webview.postMessage({ + command: "aiToolsData", + error: err.message ?? String(err), + }); + } + } +``` + +### Step 4 — Add `_handleInstallAiTools` method + +Add this method to `HomescreenViewProvider` (after `_handleAiToolsExpanded`): + +```typescript + private async _handleInstallAiTools( + skills: string[], + ideTarget: string, + mcpServers: string[] + ): Promise { + const view = this._webviewView; + if (!view) { return; } + + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + view.webview.postMessage({ command: "aiToolsResult", errors: ["No workspace folder open."] }); + return; + } + const rootUri = workspaceFolders[0].uri; + const errors: string[] = []; + + // Install skills + const cachedSkills = this._cachedSkills ?? []; + for (const dirName of skills) { + const skillInfo = cachedSkills.find((s) => s.dirName === dirName); + if (!skillInfo) { continue; } + + let content: string; + try { + // fetchSkillContent is imported from aiToolsService + const { fetchSkillContent: _fetch } = await import("../aiToolsService"); + content = await _fetch(dirName); + } catch (err: any) { + errors.push(`${dirName}: ${err.message}`); + view.webview.postMessage({ command: "aiToolsProgress", item: dirName, status: "error" }); + continue; + } + + const createdFiles: string[] = []; + try { + if (ideTarget === "Claude Code") { + await installForClaudeCode(rootUri, dirName, content, createdFiles, errors); + } else if (ideTarget === "Cursor") { + await installForCursor(rootUri, dirName, content, createdFiles); + } else { + await installForCopilot(rootUri, skillInfo.name, content, createdFiles); + } + view.webview.postMessage({ command: "aiToolsProgress", item: dirName, status: "done" }); + } catch (err: any) { + errors.push(`${dirName}: ${err.message}`); + view.webview.postMessage({ command: "aiToolsProgress", item: dirName, status: "error" }); + } + } + + // Install MCP servers + if (mcpServers.length > 0) { + const editor = detectEditor(); + const createdFiles: string[] = []; + try { + await installMcpServers(rootUri, editor, mcpServers, createdFiles); + for (const key of mcpServers) { + view.webview.postMessage({ command: "aiToolsProgress", item: key, status: "done" }); + } + } catch (err: any) { + errors.push(`MCP: ${err.message}`); + for (const key of mcpServers) { + view.webview.postMessage({ command: "aiToolsProgress", item: key, status: "error" }); + } + } + } + + // Invalidate cached installed state so next open re-reads disk + this._cachedSkills = undefined; + + view.webview.postMessage({ command: "aiToolsResult", errors }); + } +``` + +### Step 5 — Update the `onDidReceiveMessage` switch + +In `resolveWebviewView`, the message handler currently has a `switch` block. The message type must be widened and two new cases must be added. Replace the entire `onDidReceiveMessage` call with: + +```typescript + webviewView.webview.onDidReceiveMessage( + (message: { command: string; skills?: string[]; ideTarget?: string; mcpServers?: string[] }) => { + switch (message.command) { + case "openGlobalConfig": + vscode.commands.executeCommand("cloudinary.openGlobalConfig"); + break; + case "showLibrary": + vscode.commands.executeCommand("cloudinary.showLibrary"); + break; + case "openUploadWidget": + vscode.commands.executeCommand("cloudinary.openUploadWidget"); + break; + case "openWelcomeScreen": + vscode.commands.executeCommand("cloudinary.openWelcomeScreen"); + break; + case "aiToolsExpanded": + this._handleAiToolsExpanded(); + break; + case "installAiTools": + this._handleInstallAiTools( + message.skills ?? [], + message.ideTarget ?? "Claude Code", + message.mcpServers ?? [] + ); + break; + } + } + ); +``` + +Note: The `case "configureAiTools"` is intentionally removed — the old command dispatch is replaced by the new inline flow. + +### Step 6 — Fix the dynamic import of `fetchSkillContent` + +The dynamic `import()` inside `_handleInstallAiTools` in Step 4 is unnecessarily complex and won't work well with esbuild. Replace the entire `try` block that fetches skill content with a direct call using the top-level import. Change this section inside the `for (const dirName of skills)` loop in `_handleInstallAiTools`: + +```typescript + let content: string; + try { + // fetchSkillContent is imported from aiToolsService + const { fetchSkillContent: _fetch } = await import("../aiToolsService"); + content = await _fetch(dirName); + } catch (err: any) { + errors.push(`${dirName}: ${err.message}`); + view.webview.postMessage({ command: "aiToolsProgress", item: dirName, status: "error" }); + continue; + } +``` + +With this (using the top-level import): + +```typescript + let content: string; + try { + content = await fetchSkillContent(dirName); + } catch (err: any) { + errors.push(`${dirName}: ${err.message}`); + view.webview.postMessage({ command: "aiToolsProgress", item: dirName, status: "error" }); + continue; + } +``` + +Also add `fetchSkillContent` to the import list added in Step 1 of this task (it should already be there if you followed Step 1 exactly). + +### Step 7 — Verify types compile + +```bash +cd /Users/nickbradley/dev/cloudinary-vscode && npm run check-types +``` + +Expected: no errors. + +### Step 8 — Commit + +```bash +cd /Users/nickbradley/dev/cloudinary-vscode && git add src/webview/homescreenView.ts && git commit -m "feat: add aiToolsExpanded and installAiTools message handlers to HomescreenViewProvider" +``` + +--- + +## Task 4: Rewrite `src/webview/client/homescreen.ts` + +**Files:** +- Modify: `src/webview/client/homescreen.ts` + +Replace the entire file `/Users/nickbradley/dev/cloudinary-vscode/src/webview/client/homescreen.ts` with the client-side accordion state machine. + +### Step 1 — Write the new `homescreen.ts` + +Replace the file contents with: + +```typescript +import { initCommon, getVSCode } from "./common"; + +// ── Types (mirrored from aiToolsService — no import possible in webview client) ── + +interface SkillInfo { + name: string; + description: string; + dirName: string; +} + +interface McpServerInfo { + key: string; + label: string; + description: string; +} + +interface AiToolsDataMessage { + command: "aiToolsData"; + skills: SkillInfo[]; + installedByIde: Record; // ideLabel → array of dirNames + mcpServers: McpServerInfo[]; + configuredMcpKeys: string[]; + detectedIde: string; + error?: string; +} + +interface AiToolsProgressMessage { + command: "aiToolsProgress"; + item: string; // skill dirName or MCP key + status: "done" | "error"; +} + +interface AiToolsResultMessage { + command: "aiToolsResult"; + errors: string[]; +} + +type InboundMessage = AiToolsDataMessage | AiToolsProgressMessage | AiToolsResultMessage; + +// ── Module state ────────────────────────────────────────────────────────────── + +let _isOpen = false; +let _dataFetched = false; +let _cachedData: Omit | null = null; +let _activeIde = "Claude Code"; + +// ── DOM helpers ─────────────────────────────────────────────────────────────── + +function el(id: string): T { + return document.getElementById(id) as T; +} + +function show(id: string): void { + el(id).classList.remove("hidden"); +} + +function hide(id: string): void { + el(id).classList.add("hidden"); +} + +// ── State rendering ─────────────────────────────────────────────────────────── + +function showPanelState(state: "loading" | "ready" | "done" | "error"): void { + for (const s of ["loading", "ready", "done", "error"] as const) { + const elem = el(`hs-ai-state-${s}`); + if (elem) { + elem.classList.toggle("hidden", s !== state); + } + } +} + +// ── IDE pill ────────────────────────────────────────────────────────────────── + +function movePill(btn: HTMLElement): void { + const pill = el("hs-ai-ide-pill"); + if (!pill) { return; } + pill.style.left = btn.offsetLeft + "px"; + pill.style.width = btn.offsetWidth + "px"; +} + +function initPill(): void { + const activeBtn = document.querySelector(".hs-ai-ide-btn.active"); + if (activeBtn) { movePill(activeBtn); } +} + +// ── Checklist rendering ─────────────────────────────────────────────────────── + +function renderSkillRows( + skills: SkillInfo[], + installedDirNames: string[] +): void { + const list = el("hs-ai-skills-list"); + if (!list) { return; } + const installedSet = new Set(installedDirNames); + list.innerHTML = skills + .map((s) => { + const isInstalled = installedSet.has(s.dirName); + const statusClass = isInstalled ? "hs-ai-item-status--ok" : "hs-ai-item-status--none"; + const statusText = isInstalled ? "installed" : "—"; + return ``; + }) + .join(""); + list.querySelectorAll(".hs-ai-cb").forEach((cb) => { + cb.addEventListener("change", updateApplyButton); + }); +} + +function renderMcpRows( + servers: McpServerInfo[], + configuredKeys: string[] +): void { + const list = el("hs-ai-mcp-list"); + if (!list) { return; } + const configuredSet = new Set(configuredKeys); + list.innerHTML = servers + .map((s) => { + const isConfigured = configuredSet.has(s.key); + const statusClass = isConfigured ? "hs-ai-item-status--ok" : "hs-ai-item-status--none"; + const statusText = isConfigured ? "configured" : "—"; + return ``; + }) + .join(""); + list.querySelectorAll(".hs-ai-cb").forEach((cb) => { + cb.addEventListener("change", updateApplyButton); + }); +} + +function updateApplyButton(): void { + const applyBtn = el("hs-ai-apply"); + if (!applyBtn) { return; } + const anyChecked = [...document.querySelectorAll(".hs-ai-cb")] + .some((c) => c.checked && !c.disabled); + applyBtn.disabled = !anyChecked; +} + +// ── Accordion toggle ────────────────────────────────────────────────────────── + +function toggleAccordion(): void { + _isOpen = !_isOpen; + + const panel = el("hs-ai-panel"); + const btn = el("hs-btn-ai-tools"); + const chevron = el("hs-ai-chevron"); + + panel.classList.toggle("open", _isOpen); + btn.classList.toggle("expanded", _isOpen); + btn.setAttribute("aria-expanded", String(_isOpen)); + chevron.classList.toggle("hs-chevron--open", _isOpen); + + if (_isOpen && !_dataFetched) { + _dataFetched = true; + showPanelState("loading"); + getVSCode()?.postMessage({ command: "aiToolsExpanded" }); + } + + if (_isOpen) { + // Re-position pill after layout settles (accordion may have just opened) + panel.addEventListener("transitionend", () => { + initPill(); + }, { once: true }); + } +} + +// ── Apply ───────────────────────────────────────────────────────────────────── + +function handleApply(): void { + if (!_cachedData) { return; } + + const skillCheckboxes = document.querySelectorAll(".hs-ai-cb[data-skill]"); + const mcpCheckboxes = document.querySelectorAll(".hs-ai-cb[data-mcp]"); + + const selectedSkills = [...skillCheckboxes] + .filter((c) => c.checked) + .map((c) => c.dataset.skill!); + + const selectedMcpKeys = [...mcpCheckboxes] + .filter((c) => c.checked) + .map((c) => c.dataset.mcp!); + + // Switch to applying visual: disable checkboxes and apply button + document.querySelectorAll(".hs-ai-cb").forEach((c) => { c.disabled = true; }); + const applyBtn = el("hs-ai-apply"); + if (applyBtn) { + applyBtn.disabled = true; + applyBtn.textContent = "Applying…"; + } + + getVSCode()?.postMessage({ + command: "installAiTools", + skills: selectedSkills, + ideTarget: _activeIde, + mcpServers: selectedMcpKeys, + }); +} + +// ── Message handling ────────────────────────────────────────────────────────── + +function handleAiToolsData(msg: AiToolsDataMessage): void { + if (msg.error) { + el("hs-ai-error-msg").textContent = msg.error; + showPanelState("error"); + return; + } + + _cachedData = { + skills: msg.skills, + installedByIde: msg.installedByIde, + mcpServers: msg.mcpServers, + configuredMcpKeys: msg.configuredMcpKeys, + detectedIde: msg.detectedIde, + }; + + // Set active IDE to detected editor + _activeIde = msg.detectedIde; + document.querySelectorAll(".hs-ai-ide-btn").forEach((btn) => { + const isActive = btn.dataset.ide === _activeIde; + btn.classList.toggle("active", isActive); + }); + + renderSkillRows(msg.skills, msg.installedByIde[_activeIde] ?? []); + renderMcpRows(msg.mcpServers, msg.configuredMcpKeys); + + showPanelState("ready"); + updateApplyButton(); + + requestAnimationFrame(() => { initPill(); }); +} + +function handleAiToolsProgress(msg: AiToolsProgressMessage): void { + // Find the row by data-skill or data-mcp attribute + const cb = document.querySelector( + `[data-skill="${msg.item}"], [data-mcp="${msg.item}"]` + ); + if (!cb) { return; } + + const row = cb.closest(".hs-ai-item"); + if (!row) { return; } + + // Remove existing tick if any + row.querySelector(".hs-ai-item-tick")?.remove(); + + const tick = document.createElement("span"); + tick.className = `hs-ai-item-tick hs-ai-item-tick--${msg.status === "done" ? "ok" : "err"}`; + tick.textContent = msg.status === "done" ? "✓" : "✕"; + row.appendChild(tick); +} + +function handleAiToolsResult(msg: AiToolsResultMessage): void { + // Force a re-fetch next time the panel opens so installed state is fresh + _dataFetched = false; + _cachedData = null; + + // Build done state: collect rows with ticks and show them + const doneSkillsDiv = el("hs-ai-done-skills-list"); + const doneMcpDiv = el("hs-ai-done-mcp-list"); + + if (doneSkillsDiv) { + const rows = document.querySelectorAll("#hs-ai-skills-list .hs-ai-item"); + doneSkillsDiv.innerHTML = ""; + rows.forEach((row) => { + const tick = row.querySelector(".hs-ai-item-tick"); + if (!tick) { return; } + const name = row.querySelector(".hs-ai-item-name")?.textContent ?? ""; + const isOk = tick.classList.contains("hs-ai-item-tick--ok"); + const statusClass = isOk ? "hs-ai-item-status--ok" : "hs-ai-item-status--none"; + const statusText = isOk ? "installed" : "error"; + doneSkillsDiv.insertAdjacentHTML( + "beforeend", + `
+ ${isOk ? "✓" : "✕"} + ${name} + ${statusText} +
` + ); + }); + } + + if (doneMcpDiv) { + const rows = document.querySelectorAll("#hs-ai-mcp-list .hs-ai-item"); + doneMcpDiv.innerHTML = ""; + rows.forEach((row) => { + const tick = row.querySelector(".hs-ai-item-tick"); + if (!tick) { return; } + const name = row.querySelector(".hs-ai-item-name")?.textContent ?? ""; + const isOk = tick.classList.contains("hs-ai-item-tick--ok"); + const statusClass = isOk ? "hs-ai-item-status--ok" : "hs-ai-item-status--none"; + const statusText = isOk ? "configured" : "error"; + doneMcpDiv.insertAdjacentHTML( + "beforeend", + `
+ ${isOk ? "✓" : "✕"} + ${name} + ${statusText} +
` + ); + }); + } + + showPanelState("done"); +} + +// ── Init ────────────────────────────────────────────────────────────────────── + +function init(): void { + initCommon(); + + // Standard action buttons (non-accordion) + document.querySelectorAll(".hs-action:not(#hs-btn-ai-tools)").forEach((btn) => { + btn.addEventListener("click", () => { + getVSCode()?.postMessage({ command: btn.dataset.command }); + }); + }); + + // Setup banner configure button + document.getElementById("hs-btn-configure")?.addEventListener("click", () => { + getVSCode()?.postMessage({ command: "openGlobalConfig" }); + }); + + // Welcome guide footer link + document.getElementById("hs-link-welcome")?.addEventListener("click", () => { + getVSCode()?.postMessage({ command: "openWelcomeScreen" }); + }); + + // Browse Library + document.getElementById("hs-btn-library")?.addEventListener("click", () => { + getVSCode()?.postMessage({ command: "showLibrary" }); + }); + + // Upload + document.getElementById("hs-btn-upload")?.addEventListener("click", () => { + getVSCode()?.postMessage({ command: "openUploadWidget" }); + }); + + // Accordion toggle + el("hs-btn-ai-tools").addEventListener("click", toggleAccordion); + + // IDE selector buttons + document.querySelectorAll(".hs-ai-ide-btn").forEach((btn) => { + btn.addEventListener("click", () => { + if (!_cachedData) { return; } + document.querySelectorAll(".hs-ai-ide-btn").forEach((b) => b.classList.remove("active")); + btn.classList.add("active"); + movePill(btn); + _activeIde = btn.dataset.ide ?? "Claude Code"; + renderSkillRows(_cachedData.skills, _cachedData.installedByIde[_activeIde] ?? []); + updateApplyButton(); + }); + }); + + // Apply button + el("hs-ai-apply")?.addEventListener("click", handleApply); + + // Apply again button (done state) + el("hs-ai-apply-again")?.addEventListener("click", () => { + // Re-open: reset state and re-fetch + _isOpen = false; + toggleAccordion(); + }); + + // VS Code → webview messages + window.addEventListener("message", (event: MessageEvent) => { + const msg = event.data; + switch (msg.command) { + case "aiToolsData": + handleAiToolsData(msg as AiToolsDataMessage); + break; + case "aiToolsProgress": + handleAiToolsProgress(msg as AiToolsProgressMessage); + break; + case "aiToolsResult": + handleAiToolsResult(msg as AiToolsResultMessage); + break; + } + }); +} + +document.addEventListener("DOMContentLoaded", init); +``` + +### Step 2 — Verify types compile + +```bash +cd /Users/nickbradley/dev/cloudinary-vscode && npm run check-types +``` + +Expected: no errors. + +### Step 3 — Build and run tests + +```bash +cd /Users/nickbradley/dev/cloudinary-vscode && npm run compile-tests && npm run test +``` + +Expected: all existing tests pass. The test suite exercises extension activation and tree view — neither of which was structurally changed. + +### Step 4 — Commit + +```bash +cd /Users/nickbradley/dev/cloudinary-vscode && git add src/webview/client/homescreen.ts && git commit -m "feat: rewrite homescreen client with AI tools accordion state machine" +``` + +--- + +## Self-Review + +### Spec coverage + +| Spec requirement | Covered by | +|---|---| +| Replace QuickPick with inline accordion | Task 2 (HTML/CSS), Task 3 (handlers), Task 4 (client) | +| Toggle accordion on "Configure AI Tools" row click | Task 4 `toggleAccordion()` | +| Loading skeleton → Ready checklist | Task 4 `showPanelState`, `handleAiToolsData` | +| Skills section: IDE segmented control (3 buttons) | Task 2 HTML, Task 4 IDE button listeners | +| MCP Servers section: checklist per server | Task 2 HTML, Task 4 `renderMcpRows` | +| Apply button at bottom | Task 2 HTML, Task 4 `handleApply` | +| `aiToolsExpanded` post from webview | Task 4 `toggleAccordion` | +| `aiToolsData` response (skills, installedByIde, mcpServers, configuredMcpKeys, detectedIde) | Task 3 `_handleAiToolsExpanded` | +| Error case: `aiToolsData` with `error` field | Task 3 `_handleAiToolsExpanded` catch, Task 4 `handleAiToolsData` | +| `installAiTools` post with skills, ideTarget, mcpServers | Task 4 `handleApply` | +| `aiToolsProgress` per item | Task 3 `_handleInstallAiTools`, Task 4 `handleAiToolsProgress` | +| `aiToolsResult` final message | Task 3 `_handleInstallAiTools`, Task 4 `handleAiToolsResult` | +| IDE pill slider with JS positioning | Task 4 `movePill`, `initPill` | +| Applying state: disabled checkboxes + "Applying…" text | Task 4 `handleApply` | +| Done state: show installed/configured items with tick | Task 4 `handleAiToolsResult` | +| "Apply again" resets to re-fetch | Task 4 `hs-ai-apply-again` handler | +| CSS uses `--vscode-*` variables only | Task 2 CSS (hardcoded colours only in gradient/status-dot where VS Code has no equivalent variable) | +| Business logic shared between command and webview | Task 1 `src/aiToolsService.ts` | +| `installMcpServers` new function in service | Task 1 | + +### Placeholder scan + +No TBDs, no "similar to" references, no steps without code blocks. + +### Type consistency + +- `SkillInfo.dirName` — defined in `aiToolsService.ts` Task 1, used identically in Task 3 (`_handleAiToolsExpanded`, `_handleInstallAiTools`) and Task 4 (`renderSkillRows`, `handleAiToolsProgress`). +- `McpServerDef.key` / `McpServerInfo.key` — `McpServerDef` in service; the webview-facing `McpServerInfo` mirrors `{ key, label, description }` exactly as sent by `_handleAiToolsExpanded`. +- `installedByIde` — `Record` sent from Task 3, read as `Record` in Task 4. +- `AiToolsDataMessage.installedByIde` — indexed by `_activeIde` string in `renderSkillRows` calls; IDE button `data-ide` values in HTML (`"Claude Code"`, `"Cursor"`, `"VS Code (Copilot)"`) match the keys produced by `_handleAiToolsExpanded`'s `ideLabels` array. +- `showPanelState` IDs: `hs-ai-state-loading`, `hs-ai-state-ready`, `hs-ai-state-done`, `hs-ai-state-error` — all four exist in the HTML added in Task 2. +- Apply button ID `hs-ai-apply` — present in HTML (Task 2) and queried in client (Task 4). +- `hs-ai-apply-again` — present in HTML done state (Task 2) and queried in client (Task 4). From a8f78f8cde4e951f8a18f82f01d390833c35c007 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Thu, 2 Apr 2026 09:15:07 +0100 Subject: [PATCH 45/72] refactor: extract AI tools business logic into aiToolsService Move all types, helpers, and installation logic from configureAiTools.ts into a new src/aiToolsService.ts so the webview provider can also import it. Co-Authored-By: Claude Sonnet 4.6 --- src/aiToolsService.ts | 438 ++++++++++++++++++++++++++++ src/commands/configureAiTools.ts | 474 ++----------------------------- 2 files changed, 462 insertions(+), 450 deletions(-) create mode 100644 src/aiToolsService.ts diff --git a/src/aiToolsService.ts b/src/aiToolsService.ts new file mode 100644 index 0000000..0113caf --- /dev/null +++ b/src/aiToolsService.ts @@ -0,0 +1,438 @@ +import * as vscode from "vscode"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type EditorType = "cursor" | "vscode" | "windsurf" | "antigravity" | "unknown"; + +export type McpServerDef = { + label: string; + description: string; + key: string; + config: Record; +}; + +export type SkillInfo = { + name: string; + description: string; + dirName: string; +}; + +type GitHubEntry = { + name: string; + type: "file" | "dir"; +}; + +type GitHubFile = { + content: string; // base64-encoded + encoding: string; +}; + +// ── Editor detection ────────────────────────────────────────────────────────── + +export function detectEditor(): EditorType { + const uriScheme = vscode.env.uriScheme.toLowerCase(); + if (uriScheme === "cursor") { return "cursor"; } + if (uriScheme === "windsurf") { return "windsurf"; } + if (uriScheme === "antigravity" || uriScheme === "gemini") { return "antigravity"; } + if (uriScheme === "vscode" || uriScheme === "vscode-insiders") { return "vscode"; } + const appName = vscode.env.appName.toLowerCase(); + if (appName.includes("cursor")) { return "cursor"; } + if (appName.includes("windsurf")) { return "windsurf"; } + if (appName.includes("antigravity") || appName.includes("gemini")) { return "antigravity"; } + if (appName.includes("visual studio code") || appName.includes("vscode")) { return "vscode"; } + return "unknown"; +} + +export function getMcpFilePath(editor: EditorType): string { + switch (editor) { + case "cursor": return ".cursor/mcp.json"; + case "windsurf": return ".windsurf/mcp.json"; + case "antigravity": return ".agent/mcp_config.json"; + case "vscode": + default: return ".vscode/mcp.json"; + } +} + +// ── GitHub API helpers ──────────────────────────────────────────────────────── + +const SKILLS_BASE = "https://api.github.com/repos/cloudinary-devs/skills/contents"; + +export async function githubFetchJson(url: string): Promise { + const baseHeaders: Record = { Accept: "application/vnd.github+json" }; + + let response = await fetch(url, { headers: baseHeaders }); + + if (!response.ok && [401, 403, 404].includes(response.status)) { + try { + const session = await vscode.authentication.getSession("github", ["repo"], { createIfNone: true }); + if (session) { + response = await fetch(url, { + headers: { ...baseHeaders, Authorization: `Bearer ${session.accessToken}` }, + }); + } + } catch { + // auth declined or unavailable — fall through with original error + } + } + + if (!response.ok) { + throw new Error(`GitHub API ${response.status}: ${url}`); + } + return response.json() as Promise; +} + +export function decodeBase64(encoded: string): string { + return Buffer.from(encoded.replace(/\n/g, ""), "base64").toString("utf-8"); +} + +// ── Frontmatter helpers ─────────────────────────────────────────────────────── + +export function parseFrontmatter(content: string): Record { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) { return {}; } + const result: Record = {}; + for (const line of match[1].split("\n")) { + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) { continue; } + const key = line.slice(0, colonIdx).trim(); + const value = line.slice(colonIdx + 1).trim(); + if (key) { result[key] = value; } + } + return result; +} + +export function getBodyAfterFrontmatter(content: string): string { + return content.replace(/^---\n[\s\S]*?\n---\n?/, "").trim(); +} + +export function toMdcContent(content: string): string { + return content.replace(/^(---\n)([\s\S]*?)(\n---)/, (_, open, body, close) => { + const filtered = body + .split("\n") + .filter((line: string) => !line.startsWith("name:")) + .join("\n"); + return `${open}${filtered}${close}`; + }); +} + +// ── Skill fetching ──────────────────────────────────────────────────────────── + +export async function fetchSkillList(): Promise { + const entries = await githubFetchJson(`${SKILLS_BASE}/skills`); + const dirs = entries.filter((e) => e.type === "dir"); + + const results = await Promise.all( + dirs.map(async (dir): Promise => { + try { + const file = await githubFetchJson( + `${SKILLS_BASE}/skills/${dir.name}/SKILL.md` + ); + const content = decodeBase64(file.content); + const fm = parseFrontmatter(content); + return { name: fm.name || dir.name, description: fm.description || "", dirName: dir.name }; + } catch { + return null; + } + }) + ); + + return results.filter((s): s is SkillInfo => s !== null); +} + +export async function fetchSkillContent(skillName: string): Promise { + const file = await githubFetchJson( + `${SKILLS_BASE}/skills/${skillName}/SKILL.md` + ); + return decodeBase64(file.content); +} + +export async function fetchReferenceFiles( + skillName: string +): Promise> { + let entries: GitHubEntry[]; + try { + entries = await githubFetchJson( + `${SKILLS_BASE}/skills/${skillName}/references` + ); + } catch { + return []; + } + + const results = await Promise.all( + entries + .filter((e) => e.type === "file") + .map(async (e) => { + try { + const file = await githubFetchJson( + `${SKILLS_BASE}/skills/${skillName}/references/${e.name}` + ); + return { name: e.name, content: decodeBase64(file.content) }; + } catch { + return null; + } + }) + ); + return results.filter((f): f is { name: string; content: string } => f !== null); +} + +// ── Filesystem helpers ──────────────────────────────────────────────────────── + +export async function ensureDir(uri: vscode.Uri): Promise { + try { await vscode.workspace.fs.createDirectory(uri); } catch { /* already exists */ } +} + +export async function writeWithOverwriteCheck( + uri: vscode.Uri, + content: string, + label: string +): Promise { + try { + await vscode.workspace.fs.stat(uri); + const answer = await vscode.window.showWarningMessage( + `${label} already exists. Overwrite?`, + "Yes", + "No" + ); + if (answer !== "Yes") { return false; } + } catch { + // file doesn't exist — proceed + } + await ensureDir(vscode.Uri.joinPath(uri, "..")); + await vscode.workspace.fs.writeFile(uri, Buffer.from(content, "utf-8")); + return true; +} + +// ── Skill installation — per IDE ────────────────────────────────────────────── + +export async function installForClaudeCode( + rootUri: vscode.Uri, + skillName: string, + skillContent: string, + createdFiles: string[], + errors: string[] +): Promise { + const skillFile = vscode.Uri.joinPath( + rootUri, `.claude/skills/${skillName}/SKILL.md` + ); + const written = await writeWithOverwriteCheck( + skillFile, skillContent, `${skillName}/SKILL.md` + ); + if (!written) { return; } + createdFiles.push(`.claude/skills/${skillName}/SKILL.md`); + + let refs: Array<{ name: string; content: string }>; + try { + refs = await fetchReferenceFiles(skillName); + } catch (err: any) { + errors.push(`${skillName} references: ${err.message}`); + return; + } + + for (const ref of refs) { + try { + const refUri = vscode.Uri.joinPath( + rootUri, `.claude/skills/${skillName}/references/${ref.name}` + ); + await ensureDir(vscode.Uri.joinPath(refUri, "..")); + await vscode.workspace.fs.writeFile(refUri, Buffer.from(ref.content, "utf-8")); + createdFiles.push(`.claude/skills/${skillName}/references/${ref.name}`); + } catch (err: any) { + errors.push(`${skillName}/references/${ref.name}: ${err.message}`); + } + } +} + +export async function installForCursor( + rootUri: vscode.Uri, + skillName: string, + skillContent: string, + createdFiles: string[] +): Promise { + const mdcUri = vscode.Uri.joinPath(rootUri, `.cursor/rules/${skillName}.mdc`); + const written = await writeWithOverwriteCheck( + mdcUri, toMdcContent(skillContent), `${skillName}.mdc` + ); + if (written) { createdFiles.push(`.cursor/rules/${skillName}.mdc`); } +} + +export async function installForCopilot( + rootUri: vscode.Uri, + skillName: string, + skillContent: string, + createdFiles: string[] +): Promise { + const instructionsUri = vscode.Uri.joinPath( + rootUri, ".github/copilot-instructions.md" + ); + await ensureDir(vscode.Uri.joinPath(rootUri, ".github")); + + let existing = ""; + try { + const bytes = await vscode.workspace.fs.readFile(instructionsUri); + existing = Buffer.from(bytes).toString("utf-8"); + } catch { + // new file + } + + if (existing.includes(`## ${skillName}`)) { + if (!createdFiles.includes(".github/copilot-instructions.md")) { + createdFiles.push(".github/copilot-instructions.md"); + } + return; + } + + const body = getBodyAfterFrontmatter(skillContent); + const section = `## ${skillName}\n\n${body}\n`; + const separator = existing.length > 0 ? "\n" : ""; + + await vscode.workspace.fs.writeFile( + instructionsUri, + Buffer.from(existing + separator + section, "utf-8") + ); + + if (!createdFiles.includes(".github/copilot-instructions.md")) { + createdFiles.push(".github/copilot-instructions.md"); + } +} + +// ── Status detection ────────────────────────────────────────────────────────── + +export async function readInstalledSkillDirNames( + rootUri: vscode.Uri, + ideTargetLabel: string, + skills: SkillInfo[] +): Promise> { + const installed = new Set(); + + if (ideTargetLabel === "VS Code (Copilot)") { + try { + const uri = vscode.Uri.joinPath(rootUri, ".github/copilot-instructions.md"); + const bytes = await vscode.workspace.fs.readFile(uri); + const content = Buffer.from(bytes).toString("utf-8"); + for (const skill of skills) { + if (content.includes(`## ${skill.name}`)) { + installed.add(skill.dirName); + } + } + } catch { + // file not found — nothing installed + } + return installed; + } + + await Promise.all( + skills.map(async (skill) => { + try { + const checkPath = + ideTargetLabel === "Claude Code" + ? `.claude/skills/${skill.dirName}/SKILL.md` + : `.cursor/rules/${skill.dirName}.mdc`; + await vscode.workspace.fs.stat(vscode.Uri.joinPath(rootUri, checkPath)); + installed.add(skill.dirName); + } catch { + // not installed + } + }) + ); + return installed; +} + +export async function readConfiguredMcpServerKeys( + rootUri: vscode.Uri, + mcpFilePath: string, + rootKey: string +): Promise> { + try { + const uri = vscode.Uri.joinPath(rootUri, mcpFilePath); + const bytes = await vscode.workspace.fs.readFile(uri); + const config = JSON.parse(Buffer.from(bytes).toString("utf-8")); + const servers = config[rootKey]; + if (servers && typeof servers === "object") { + return new Set(Object.keys(servers)); + } + } catch { + // file not found or invalid JSON + } + return new Set(); +} + +// ── MCP Server definitions ──────────────────────────────────────────────────── + +export const MCP_SERVERS: McpServerDef[] = [ + { + label: "Cloudinary Asset Management", + description: "Browse, upload, and manage media assets", + key: "cloudinary-asset-mgmt", + config: { url: "https://asset-management.mcp.cloudinary.com/mcp" }, + }, + { + label: "Cloudinary Environment Config", + description: "Configure upload presets, transformations, and settings", + key: "cloudinary-env-config", + config: { url: "https://environment-config.mcp.cloudinary.com/mcp" }, + }, + { + label: "Cloudinary Structured Metadata", + description: "Manage structured metadata fields and values", + key: "cloudinary-smd", + config: { url: "https://structured-metadata.mcp.cloudinary.com/mcp" }, + }, + { + label: "Cloudinary Analysis", + description: "AI-powered image and video analysis", + key: "cloudinary-analysis", + config: { url: "https://analysis.mcp.cloudinary.com/sse" }, + }, + { + label: "MediaFlows", + description: "AI-powered media workflows and automation", + key: "mediaflows", + config: { + url: "https://mediaflows.mcp.cloudinary.com/v2/mcp", + headers: { + "cld-cloud-name": "your_cloud_name", + "cld-api-key": "your_api_key", + "cld-secret": "your_api_secret", + }, + }, + }, +]; + +// ── MCP installation helper ─────────────────────────────────────────────────── + +export async function installMcpServers( + rootUri: vscode.Uri, + editor: EditorType, + selectedKeys: string[], + createdFiles: string[] +): Promise { + const mcpFilePath = getMcpFilePath(editor); + const isVscode = editor === "vscode"; + const rootKey = isVscode ? "servers" : "mcpServers"; + const mcpUri = vscode.Uri.joinPath(rootUri, mcpFilePath); + let config: Record = {}; + try { + const bytes = await vscode.workspace.fs.readFile(mcpUri); + config = JSON.parse(Buffer.from(bytes).toString("utf-8")); + } catch { + // new file + } + if (!config[rootKey] || typeof config[rootKey] !== "object") { + config[rootKey] = {}; + } + const servers = config[rootKey] as Record; + for (const key of selectedKeys) { + const def = MCP_SERVERS.find((s) => s.key === key); + if (def) { + servers[def.key] = def.config; + } + } + await ensureDir(vscode.Uri.joinPath(mcpUri, "..")); + await vscode.workspace.fs.writeFile( + mcpUri, + Buffer.from(JSON.stringify(config, null, 2), "utf-8") + ); + if (!createdFiles.includes(mcpFilePath)) { + createdFiles.push(mcpFilePath); + } +} diff --git a/src/commands/configureAiTools.ts b/src/commands/configureAiTools.ts index 03960c2..6f1bc75 100644 --- a/src/commands/configureAiTools.ts +++ b/src/commands/configureAiTools.ts @@ -1,421 +1,22 @@ import * as vscode from "vscode"; - -// ── Types ──────────────────────────────────────────────────────────────────── - -type EditorType = "cursor" | "vscode" | "windsurf" | "antigravity" | "unknown"; - -type McpServerDef = { - label: string; - description: string; - key: string; - config: Record; // same for all editors; root key differs by editor -}; - -type SkillInfo = { - name: string; - description: string; - dirName: string; // GitHub directory name, used for API paths and local install paths -}; - -type GitHubEntry = { - name: string; - type: "file" | "dir"; -}; - -type GitHubFile = { - content: string; // base64-encoded - encoding: string; -}; - -// ── Editor detection (same logic as legacy setupWorkspace) ─────────────────── - -function detectEditor(): EditorType { - const uriScheme = vscode.env.uriScheme.toLowerCase(); - if (uriScheme === "cursor") { return "cursor"; } - if (uriScheme === "windsurf") { return "windsurf"; } - if (uriScheme === "antigravity" || uriScheme === "gemini") { return "antigravity"; } - if (uriScheme === "vscode" || uriScheme === "vscode-insiders") { return "vscode"; } - const appName = vscode.env.appName.toLowerCase(); - if (appName.includes("cursor")) { return "cursor"; } - if (appName.includes("windsurf")) { return "windsurf"; } - if (appName.includes("antigravity") || appName.includes("gemini")) { return "antigravity"; } - if (appName.includes("visual studio code") || appName.includes("vscode")) { return "vscode"; } - return "unknown"; -} - -function getMcpFilePath(editor: EditorType): string { - switch (editor) { - case "cursor": return ".cursor/mcp.json"; - case "windsurf": return ".windsurf/mcp.json"; - case "antigravity": return ".agent/mcp_config.json"; - case "vscode": - default: return ".vscode/mcp.json"; - } -} - -// ── GitHub API helpers ─────────────────────────────────────────────────────── - -const SKILLS_BASE = "https://api.github.com/repos/cloudinary-devs/skills/contents"; - -async function githubFetchJson(url: string): Promise { - const baseHeaders: Record = { Accept: "application/vnd.github+json" }; - - // Try unauthenticated first (works for public repos, no UI) - let response = await fetch(url, { headers: baseHeaders }); - - // On 401/403/404 attempt GitHub auth and retry once - if (!response.ok && [401, 403, 404].includes(response.status)) { - try { - const session = await vscode.authentication.getSession("github", ["repo"], { createIfNone: true }); - if (session) { - response = await fetch(url, { - headers: { ...baseHeaders, Authorization: `Bearer ${session.accessToken}` }, - }); - } - } catch { - // auth declined or unavailable — fall through with original error - } - } - - if (!response.ok) { - throw new Error(`GitHub API ${response.status}: ${url}`); - } - return response.json() as Promise; -} - -function decodeBase64(encoded: string): string { - // GitHub API returns base64 with newlines — strip them before decoding - return Buffer.from(encoded.replace(/\n/g, ""), "base64").toString("utf-8"); -} - -// ── Frontmatter helpers ────────────────────────────────────────────────────── - -function parseFrontmatter(content: string): Record { - const match = content.match(/^---\n([\s\S]*?)\n---/); - if (!match) { return {}; } - const result: Record = {}; - for (const line of match[1].split("\n")) { - const colonIdx = line.indexOf(":"); - if (colonIdx === -1) { continue; } - const key = line.slice(0, colonIdx).trim(); - const value = line.slice(colonIdx + 1).trim(); - if (key) { result[key] = value; } - } - return result; -} - -/** Returns everything after the closing --- of the frontmatter block. */ -function getBodyAfterFrontmatter(content: string): string { - return content.replace(/^---\n[\s\S]*?\n---\n?/, "").trim(); -} - -/** Returns SKILL.md content with the `name:` line removed (Cursor .mdc format). */ -function toMdcContent(content: string): string { - return content.replace(/^(---\n)([\s\S]*?)(\n---)/, (_, open, body, close) => { - const filtered = body - .split("\n") - .filter((line: string) => !line.startsWith("name:")) - .join("\n"); - return `${open}${filtered}${close}`; - }); -} - -// ── Skill fetching ─────────────────────────────────────────────────────────── - -async function fetchSkillList(): Promise { - const entries = await githubFetchJson(`${SKILLS_BASE}/skills`); - const dirs = entries.filter((e) => e.type === "dir"); - - const results = await Promise.all( - dirs.map(async (dir): Promise => { - try { - const file = await githubFetchJson( - `${SKILLS_BASE}/skills/${dir.name}/SKILL.md` - ); - const content = decodeBase64(file.content); - const fm = parseFrontmatter(content); - return { name: fm.name || dir.name, description: fm.description || "", dirName: dir.name }; - } catch { - return null; - } - }) - ); - - return results.filter((s): s is SkillInfo => s !== null); -} - -async function fetchSkillContent(skillName: string): Promise { - const file = await githubFetchJson( - `${SKILLS_BASE}/skills/${skillName}/SKILL.md` - ); - return decodeBase64(file.content); -} - -async function fetchReferenceFiles( - skillName: string -): Promise> { - let entries: GitHubEntry[]; - try { - entries = await githubFetchJson( - `${SKILLS_BASE}/skills/${skillName}/references` - ); - } catch { - return []; // no references directory — that's fine - } - - const results = await Promise.all( - entries - .filter((e) => e.type === "file") - .map(async (e) => { - try { - const file = await githubFetchJson( - `${SKILLS_BASE}/skills/${skillName}/references/${e.name}` - ); - return { name: e.name, content: decodeBase64(file.content) }; - } catch { - return null; - } - }) - ); - return results.filter((f): f is { name: string; content: string } => f !== null); -} - -// ── Filesystem helpers ─────────────────────────────────────────────────────── - -async function ensureDir(uri: vscode.Uri): Promise { - try { await vscode.workspace.fs.createDirectory(uri); } catch { /* already exists */ } -} - -/** - * Writes content to uri. If the file already exists, prompts the user before - * overwriting. Returns true if the file was written, false if the user skipped. - */ -async function writeWithOverwriteCheck( - uri: vscode.Uri, - content: string, - label: string -): Promise { - try { - await vscode.workspace.fs.stat(uri); - const answer = await vscode.window.showWarningMessage( - `${label} already exists. Overwrite?`, - "Yes", - "No" - ); - if (answer !== "Yes") { return false; } - } catch { - // file doesn't exist — proceed - } - await ensureDir(vscode.Uri.joinPath(uri, "..")); - await vscode.workspace.fs.writeFile(uri, Buffer.from(content, "utf-8")); - return true; -} - -// ── Skill installation — per IDE ───────────────────────────────────────────── - -async function installForClaudeCode( - rootUri: vscode.Uri, - skillName: string, - skillContent: string, - createdFiles: string[], - errors: string[] -): Promise { - const skillFile = vscode.Uri.joinPath( - rootUri, `.claude/skills/${skillName}/SKILL.md` - ); - const written = await writeWithOverwriteCheck( - skillFile, skillContent, `${skillName}/SKILL.md` - ); - if (!written) { return; } - createdFiles.push(`.claude/skills/${skillName}/SKILL.md`); - - let refs: Array<{ name: string; content: string }>; - try { - refs = await fetchReferenceFiles(skillName); - } catch (err: any) { - errors.push(`${skillName} references: ${err.message}`); - return; - } - - for (const ref of refs) { - try { - const refUri = vscode.Uri.joinPath( - rootUri, `.claude/skills/${skillName}/references/${ref.name}` - ); - await ensureDir(vscode.Uri.joinPath(refUri, "..")); - await vscode.workspace.fs.writeFile(refUri, Buffer.from(ref.content, "utf-8")); - createdFiles.push(`.claude/skills/${skillName}/references/${ref.name}`); - } catch (err: any) { - errors.push(`${skillName}/references/${ref.name}: ${err.message}`); - } - } -} - -async function installForCursor( - rootUri: vscode.Uri, - skillName: string, - skillContent: string, - createdFiles: string[] -): Promise { - const mdcUri = vscode.Uri.joinPath(rootUri, `.cursor/rules/${skillName}.mdc`); - const written = await writeWithOverwriteCheck( - mdcUri, toMdcContent(skillContent), `${skillName}.mdc` - ); - if (written) { createdFiles.push(`.cursor/rules/${skillName}.mdc`); } -} - -async function installForCopilot( - rootUri: vscode.Uri, - skillName: string, - skillContent: string, - createdFiles: string[] -): Promise { - const instructionsUri = vscode.Uri.joinPath( - rootUri, ".github/copilot-instructions.md" - ); - await ensureDir(vscode.Uri.joinPath(rootUri, ".github")); - - let existing = ""; - try { - const bytes = await vscode.workspace.fs.readFile(instructionsUri); - existing = Buffer.from(bytes).toString("utf-8"); - } catch { - // new file - } - - if (existing.includes(`## ${skillName}`)) { - if (!createdFiles.includes(".github/copilot-instructions.md")) { - createdFiles.push(".github/copilot-instructions.md"); - } - return; - } - - const body = getBodyAfterFrontmatter(skillContent); - const section = `## ${skillName}\n\n${body}\n`; - const separator = existing.length > 0 ? "\n" : ""; - - await vscode.workspace.fs.writeFile( - instructionsUri, - Buffer.from(existing + separator + section, "utf-8") - ); - - if (!createdFiles.includes(".github/copilot-instructions.md")) { - createdFiles.push(".github/copilot-instructions.md"); - } -} - -// ── Status detection ───────────────────────────────────────────────────────── - -/** - * Returns the set of skill dirNames already installed for the given IDE target. - * Errors reading individual paths are silently treated as "not installed". - */ -async function readInstalledSkillDirNames( - rootUri: vscode.Uri, - ideTargetLabel: string, - skills: SkillInfo[] -): Promise> { - const installed = new Set(); - - if (ideTargetLabel === "VS Code (Copilot)") { - try { - const uri = vscode.Uri.joinPath(rootUri, ".github/copilot-instructions.md"); - const bytes = await vscode.workspace.fs.readFile(uri); - const content = Buffer.from(bytes).toString("utf-8"); - for (const skill of skills) { - if (content.includes(`## ${skill.name}`)) { - installed.add(skill.dirName); - } - } - } catch { - // file not found — nothing installed - } - return installed; - } - - await Promise.all( - skills.map(async (skill) => { - try { - const checkPath = - ideTargetLabel === "Claude Code" - ? `.claude/skills/${skill.dirName}/SKILL.md` - : `.cursor/rules/${skill.dirName}.mdc`; - await vscode.workspace.fs.stat(vscode.Uri.joinPath(rootUri, checkPath)); - installed.add(skill.dirName); - } catch { - // not installed - } - }) - ); - return installed; -} - -/** - * Returns the set of server keys already present in the MCP config file. - * Returns an empty Set if the file doesn't exist or can't be parsed. - */ -async function readConfiguredMcpServerKeys( - rootUri: vscode.Uri, - mcpFilePath: string, - rootKey: string -): Promise> { - try { - const uri = vscode.Uri.joinPath(rootUri, mcpFilePath); - const bytes = await vscode.workspace.fs.readFile(uri); - const config = JSON.parse(Buffer.from(bytes).toString("utf-8")); - const servers = config[rootKey]; - if (servers && typeof servers === "object") { - return new Set(Object.keys(servers)); - } - } catch { - // file not found or invalid JSON - } - return new Set(); -} - -// ── MCP Server definitions ──────────────────────────────────────────────────── - -const MCP_SERVERS: McpServerDef[] = [ - { - label: "Cloudinary Asset Management", - description: "Browse, upload, and manage media assets", - key: "cloudinary-asset-mgmt", - config: { url: "https://asset-management.mcp.cloudinary.com/mcp" }, - }, - { - label: "Cloudinary Environment Config", - description: "Configure upload presets, transformations, and settings", - key: "cloudinary-env-config", - config: { url: "https://environment-config.mcp.cloudinary.com/mcp" }, - }, - { - label: "Cloudinary Structured Metadata", - description: "Manage structured metadata fields and values", - key: "cloudinary-smd", - config: { url: "https://structured-metadata.mcp.cloudinary.com/mcp" }, - }, - { - label: "Cloudinary Analysis", - description: "AI-powered image and video analysis", - key: "cloudinary-analysis", - config: { url: "https://analysis.mcp.cloudinary.com/sse" }, - }, - { - label: "MediaFlows", - description: "AI-powered media workflows and automation", - key: "mediaflows", - config: { - url: "https://mediaflows.mcp.cloudinary.com/v2/mcp", - headers: { - "cld-cloud-name": "your_cloud_name", - "cld-api-key": "your_api_key", - "cld-secret": "your_api_secret", - }, - }, - }, -]; - -// ── MCP Config ─────────────────────────────────────────────────────────────── +import { + EditorType, + McpServerDef, + SkillInfo, + MCP_SERVERS, + detectEditor, + getMcpFilePath, + fetchSkillList, + fetchSkillContent, + installForClaudeCode, + installForCursor, + installForCopilot, + readInstalledSkillDirNames, + readConfiguredMcpServerKeys, + installMcpServers, +} from "../aiToolsService"; + +// ── MCP Config (QuickPick flow) ─────────────────────────────────────────────── async function createMcpConfig( rootUri: vscode.Uri, @@ -437,41 +38,15 @@ async function createMcpConfig( ); if (!selected || selected.length === 0) { return; } - const selectedDefs = selected + const selectedKeys = selected .map((item) => MCP_SERVERS.find((s) => s.label === item.label)) - .filter((s): s is McpServerDef => s !== undefined); - - const mcpUri = vscode.Uri.joinPath(rootUri, mcpFilePath); - - // Read and merge into existing config if present - let config: Record = {}; - try { - const bytes = await vscode.workspace.fs.readFile(mcpUri); - config = JSON.parse(Buffer.from(bytes).toString("utf-8")); - } catch { - // new file - } + .filter((s): s is McpServerDef => s !== undefined) + .map((s) => s.key); - if (!config[rootKey] || typeof config[rootKey] !== "object") { - config[rootKey] = {}; - } - const servers = config[rootKey] as Record; - - for (const def of selectedDefs) { - servers[def.key] = def.config; - } - - await ensureDir(vscode.Uri.joinPath(mcpUri, "..")); - await vscode.workspace.fs.writeFile( - mcpUri, - Buffer.from(JSON.stringify(config, null, 2), "utf-8") - ); - if (!createdFiles.includes(mcpFilePath)) { - createdFiles.push(mcpFilePath); - } + await installMcpServers(rootUri, editor, selectedKeys, createdFiles); } -// ── Command registration ───────────────────────────────────────────────────── +// ── Command registration ────────────────────────────────────────────────────── function registerConfigureAiTools(context: vscode.ExtensionContext): void { context.subscriptions.push( @@ -506,7 +81,6 @@ function registerConfigureAiTools(context: vscode.ExtensionContext): void { return; } - // IDE target first — needed to check install status before showing skills const editor = detectEditor(); const ideOptions: vscode.QuickPickItem[] = [ { label: "Claude Code", description: "Install to .claude/skills/" }, @@ -537,7 +111,7 @@ function registerConfigureAiTools(context: vscode.ExtensionContext): void { label: s.name, description: s.description, detail: installedDirNames.has(s.dirName) ? "✓ installed" : "Not installed", - picked: true, // always pre-selected; writeWithOverwriteCheck guards re-installs + picked: true, })), { canPickMany: true, placeHolder: "Select skills to install" } ); From cc301c835d55e8260adb2be3087650977e65f51a Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Thu, 2 Apr 2026 09:19:18 +0100 Subject: [PATCH 46/72] feat: add accordion HTML and CSS to homescreen sidebar --- src/webview/homescreenView.ts | 313 +++++++++++++++++++++++++++++++++- 1 file changed, 312 insertions(+), 1 deletion(-) diff --git a/src/webview/homescreenView.ts b/src/webview/homescreenView.ts index c8a043b..54db325 100644 --- a/src/webview/homescreenView.ts +++ b/src/webview/homescreenView.ts @@ -369,6 +369,266 @@ export class HomescreenViewProvider implements vscode.WebviewViewProvider { to { opacity: 1; transform: translateY(0); } } .hs-header { animation: hs-in 0.18s ease both; } + + /* ── AI Tools accordion ── */ + #hs-btn-ai-tools { user-select: none; } + #hs-btn-ai-tools.expanded { + background: var(--vscode-list-hoverBackground); + border-radius: 7px 7px 0 0; + } + + .hs-ai-panel { + overflow: hidden; + max-height: 0; + transition: max-height 0.28s cubic-bezier(0.4, 0, 0.2, 1); + border-radius: 0 0 7px 7px; + background: rgba(255,255,255,0.02); + border-top: 1px solid transparent; + } + .hs-ai-panel.open { + max-height: 520px; + border-top-color: var(--vscode-panel-border, rgba(128,128,128,0.14)); + } + .hs-ai-panel-inner { + padding: 10px 10px 12px; + display: flex; + flex-direction: column; + gap: 10px; + } + + /* Loading skeletons */ + .hs-ai-loading { display: flex; flex-direction: column; gap: 6px; } + .hs-skeleton { + height: 22px; + border-radius: 4px; + background: linear-gradient( + 90deg, + rgba(255,255,255,0.04) 0%, + rgba(255,255,255,0.09) 50%, + rgba(255,255,255,0.04) 100% + ); + background-size: 200% 100%; + animation: shimmer 1.4s ease infinite; + } + .hs-skeleton--short { width: 55%; } + .hs-skeleton--label { height: 10px; width: 38%; margin-bottom: 4px; } + @keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } + } + + /* Section headers */ + .hs-ai-section-head { + display: flex; + align-items: center; + gap: 7px; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.9px; + text-transform: uppercase; + color: var(--vscode-descriptionForeground); + opacity: 0.8; + margin-bottom: 5px; + } + .hs-ai-section-head::after { + content: ''; + flex: 1; + height: 1px; + background: var(--vscode-panel-border, rgba(128,128,128,0.14)); + } + + /* IDE segmented control */ + .hs-ai-ide { + position: relative; + display: flex; + background: rgba(255,255,255,0.04); + border: 1px solid var(--vscode-panel-border, rgba(128,128,128,0.14)); + border-radius: 5px; + padding: 2px; + margin-bottom: 7px; + } + .hs-ai-ide-pill { + position: absolute; + top: 2px; + height: calc(100% - 4px); + background: rgba(52,72,197,0.35); + border: 1px solid rgba(52,72,197,0.5); + border-radius: 3px; + transition: + left 0.15s cubic-bezier(0.4,0,0.2,1), + width 0.15s cubic-bezier(0.4,0,0.2,1); + pointer-events: none; + } + .hs-ai-ide-btn { + flex: 1; + padding: 3px 4px; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.2px; + text-align: center; + text-transform: uppercase; + background: none; + border: none; + border-radius: 3px; + color: var(--vscode-descriptionForeground); + cursor: pointer; + font-family: var(--vscode-font-family); + position: relative; + z-index: 1; + transition: color 0.15s; + white-space: nowrap; + } + .hs-ai-ide-btn.active { color: var(--vscode-foreground); } + .hs-ai-ide-btn:hover:not(.active) { color: var(--vscode-foreground); opacity: 0.7; } + + /* Checklist items */ + .hs-ai-item { + display: flex; + align-items: center; + gap: 7px; + padding: 3px 4px 3px 2px; + border-radius: 4px; + transition: background 0.1s; + cursor: pointer; + animation: hs-row-in 0.18s ease both; + } + .hs-ai-item:hover { background: var(--vscode-list-hoverBackground); } + .hs-ai-item:nth-child(1) { animation-delay: .05s; } + .hs-ai-item:nth-child(2) { animation-delay: .09s; } + .hs-ai-item:nth-child(3) { animation-delay: .13s; } + .hs-ai-item:nth-child(4) { animation-delay: .17s; } + .hs-ai-item:nth-child(5) { animation-delay: .21s; } + @keyframes hs-row-in { + from { opacity: 0; transform: translateX(-4px); } + to { opacity: 1; transform: translateX(0); } + } + + /* Custom checkbox */ + .hs-ai-cb { + appearance: none; + -webkit-appearance: none; + width: 12px; + height: 12px; + flex-shrink: 0; + border: 1.5px solid var(--vscode-checkbox-border); + border-radius: 2px; + background: var(--vscode-checkbox-background); + cursor: pointer; + position: relative; + transition: border-color 0.1s, background 0.1s; + } + .hs-ai-cb:checked { + background: var(--vscode-button-background); + border-color: var(--vscode-button-background); + } + .hs-ai-cb:checked::after { + content: ''; + position: absolute; + left: 2px; top: -1px; + width: 5px; height: 8px; + border: 1.5px solid var(--vscode-button-foreground); + border-top: none; + border-left: none; + transform: rotate(45deg); + } + .hs-ai-cb:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; + } + + .hs-ai-item-name { + flex: 1; + font-size: 11px; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + } + + /* Status indicator */ + .hs-ai-item-status { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 4px; + font-size: 9.5px; + color: var(--vscode-descriptionForeground); + white-space: nowrap; + } + .hs-ai-item-status::before { + content: ''; + display: inline-block; + width: 5px; + height: 5px; + border-radius: 1px; + flex-shrink: 0; + } + .hs-ai-item-status--ok::before { background: #4ade80; } + .hs-ai-item-status--none::before { background: rgba(255,255,255,0.15); } + + /* Progress tick */ + .hs-ai-item-tick { + flex-shrink: 0; + width: 13px; + height: 13px; + display: flex; + align-items: center; + justify-content: center; + font-size: 9px; + animation: tick-in 0.2s cubic-bezier(0.34,1.56,0.64,1) both; + } + @keyframes tick-in { + from { opacity: 0; transform: scale(0); } + to { opacity: 1; transform: scale(1); } + } + .hs-ai-item-tick--ok { color: #4ade80; } + .hs-ai-item-tick--err { color: var(--vscode-errorForeground); } + + /* Apply button */ + .hs-ai-apply { + width: 100%; + padding: 6px 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.3px; + color: var(--vscode-button-foreground); + background: var(--vscode-button-background); + border: none; + border-radius: 5px; + cursor: pointer; + font-family: var(--vscode-font-family); + transition: opacity 0.12s; + position: relative; + overflow: hidden; + margin-top: 2px; + } + .hs-ai-apply::after { + content: ''; + position: absolute; + inset: 0; + background: rgba(255,255,255,0); + transition: background 0.12s; + } + .hs-ai-apply:hover::after { background: rgba(255,255,255,0.08); } + .hs-ai-apply:disabled { opacity: 0.35; cursor: default; } + .hs-ai-apply:disabled::after { background: none; } + .hs-ai-apply:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; + } + + /* Error banner */ + .hs-ai-error { + font-size: 10.5px; + color: var(--vscode-errorForeground); + padding: 5px 7px; + border-radius: 4px; + background: rgba(241,76,76,0.08); + border: 1px solid rgba(241,76,76,0.2); + } + + .hidden { display: none !important; }
@@ -421,7 +681,7 @@ export class HomescreenViewProvider implements vscode.WebviewViewProvider { - + + +
+ + +
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + +
`; } + + private async _handleAiToolsExpanded(): Promise { + const view = this._webviewView; + if (!view) { return; } + + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + view.webview.postMessage({ + command: "aiToolsData", + error: "Please open a workspace folder first.", + }); + return; + } + const rootUri = workspaceFolders[0].uri; + + try { + // Fetch skills once; cache for subsequent opens + if (!this._cachedSkills) { + this._cachedSkills = await fetchSkillList(); + } + const skills = this._cachedSkills; + + const ideLabels: string[] = ["Claude Code", "Cursor", "VS Code (Copilot)"]; + + // Pre-compute installed status for all 3 IDEs + const installedByIde: Record = {}; + await Promise.all( + ideLabels.map(async (label) => { + const installedSet = await readInstalledSkillDirNames(rootUri, label, skills); + installedByIde[label] = [...installedSet]; + }) + ); + + // MCP servers — use detected editor for the config file path + const editor = detectEditor(); + const mcpFilePath = getMcpFilePath(editor); + const rootKey = editor === "vscode" ? "servers" : "mcpServers"; + const configuredMcpSet = await readConfiguredMcpServerKeys(rootUri, mcpFilePath, rootKey); + + const detectedIde = + editor === "cursor" ? "Cursor" : + editor === "vscode" ? "VS Code (Copilot)" : + "Claude Code"; + + view.webview.postMessage({ + command: "aiToolsData", + skills: skills.map((s) => ({ name: s.name, description: s.description, dirName: s.dirName })), + installedByIde, + mcpServers: MCP_SERVERS.map((s) => ({ key: s.key, label: s.label, description: s.description })), + configuredMcpKeys: [...configuredMcpSet], + detectedIde, + }); + } catch (err: any) { + view.webview.postMessage({ + command: "aiToolsData", + error: err.message ?? String(err), + }); + } + } + + private async _handleInstallAiTools( + skills: string[], + ideTarget: string, + mcpServers: string[] + ): Promise { + const view = this._webviewView; + if (!view) { return; } + + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + view.webview.postMessage({ command: "aiToolsResult", errors: ["No workspace folder open."] }); + return; + } + const rootUri = workspaceFolders[0].uri; + const errors: string[] = []; + + // Install skills + const cachedSkills = this._cachedSkills ?? []; + for (const dirName of skills) { + const skillInfo = cachedSkills.find((s) => s.dirName === dirName); + if (!skillInfo) { continue; } + + let content: string; + try { + content = await fetchSkillContent(dirName); + } catch (err: any) { + errors.push(`${dirName}: ${err.message}`); + view.webview.postMessage({ command: "aiToolsProgress", item: dirName, status: "error" }); + continue; + } + + const createdFiles: string[] = []; + try { + if (ideTarget === "Claude Code") { + await installForClaudeCode(rootUri, dirName, content, createdFiles, errors); + } else if (ideTarget === "Cursor") { + await installForCursor(rootUri, dirName, content, createdFiles); + } else { + await installForCopilot(rootUri, skillInfo.name, content, createdFiles); + } + view.webview.postMessage({ command: "aiToolsProgress", item: dirName, status: "done" }); + } catch (err: any) { + errors.push(`${dirName}: ${err.message}`); + view.webview.postMessage({ command: "aiToolsProgress", item: dirName, status: "error" }); + } + } + + // Install MCP servers + if (mcpServers.length > 0) { + const editor = detectEditor(); + const createdFiles: string[] = []; + try { + await installMcpServers(rootUri, editor, mcpServers, createdFiles); + for (const key of mcpServers) { + view.webview.postMessage({ command: "aiToolsProgress", item: key, status: "done" }); + } + } catch (err: any) { + errors.push(`MCP: ${err.message}`); + for (const key of mcpServers) { + view.webview.postMessage({ command: "aiToolsProgress", item: key, status: "error" }); + } + } + } + + // Invalidate cached skills so next open re-reads disk + this._cachedSkills = undefined; + + view.webview.postMessage({ command: "aiToolsResult", errors }); + } } From b9846923ec62d73e176bda31e157f2ab3a33afa9 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Thu, 2 Apr 2026 09:26:00 +0100 Subject: [PATCH 48/72] fix: await async message handlers to prevent floating promises --- src/webview/homescreenView.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/webview/homescreenView.ts b/src/webview/homescreenView.ts index d85b2fe..85ea9a2 100644 --- a/src/webview/homescreenView.ts +++ b/src/webview/homescreenView.ts @@ -64,7 +64,7 @@ export class HomescreenViewProvider implements vscode.WebviewViewProvider { }); webviewView.webview.onDidReceiveMessage( - (message: { command: string; skills?: string[]; ideTarget?: string; mcpServers?: string[] }) => { + async (message: { command: string; skills?: string[]; ideTarget?: string; mcpServers?: string[] }) => { switch (message.command) { case "openGlobalConfig": vscode.commands.executeCommand("cloudinary.openGlobalConfig"); @@ -79,10 +79,10 @@ export class HomescreenViewProvider implements vscode.WebviewViewProvider { vscode.commands.executeCommand("cloudinary.openWelcomeScreen"); break; case "aiToolsExpanded": - this._handleAiToolsExpanded(); + await this._handleAiToolsExpanded(); break; case "installAiTools": - this._handleInstallAiTools( + await this._handleInstallAiTools( message.skills ?? [], message.ideTarget ?? "Claude Code", message.mcpServers ?? [] From 7f9153762b2d3937d4d2f9cc2214e94d252e00fe Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Thu, 2 Apr 2026 09:28:43 +0100 Subject: [PATCH 49/72] feat: rewrite homescreen client with AI tools accordion state machine --- src/webview/client/homescreen.ts | 375 +++++++++++++++++++++++++++++-- 1 file changed, 360 insertions(+), 15 deletions(-) diff --git a/src/webview/client/homescreen.ts b/src/webview/client/homescreen.ts index a98db69..6241805 100644 --- a/src/webview/client/homescreen.ts +++ b/src/webview/client/homescreen.ts @@ -1,20 +1,365 @@ -/** - * Homescreen webview client-side script. - * Wires up button event listeners and posts messages to the extension host. - */ - import { initCommon, getVSCode } from "./common"; -function postMessage(command: string): void { - getVSCode()?.postMessage({ command }); +// ── Types (mirrored from aiToolsService — no import possible in webview client) ── + +interface SkillInfo { + name: string; + description: string; + dirName: string; +} + +interface McpServerInfo { + key: string; + label: string; + description: string; +} + +interface AiToolsDataMessage { + command: "aiToolsData"; + skills: SkillInfo[]; + installedByIde: Record; // ideLabel → array of dirNames + mcpServers: McpServerInfo[]; + configuredMcpKeys: string[]; + detectedIde: string; + error?: string; +} + +interface AiToolsProgressMessage { + command: "aiToolsProgress"; + item: string; // skill dirName or MCP key + status: "done" | "error"; +} + +interface AiToolsResultMessage { + command: "aiToolsResult"; + errors: string[]; +} + +type InboundMessage = AiToolsDataMessage | AiToolsProgressMessage | AiToolsResultMessage; + +// ── Module state ────────────────────────────────────────────────────────────── + +let _isOpen = false; +let _dataFetched = false; +let _cachedData: Omit | null = null; +let _activeIde = "Claude Code"; + +// ── DOM helpers ─────────────────────────────────────────────────────────────── + +function el(id: string): T { + return document.getElementById(id) as T; +} + +function show(id: string): void { + el(id).classList.remove("hidden"); +} + +function hide(id: string): void { + el(id).classList.add("hidden"); } -initCommon(); +// ── State rendering ─────────────────────────────────────────────────────────── + +function showPanelState(state: "loading" | "ready" | "done" | "error"): void { + for (const s of ["loading", "ready", "done", "error"] as const) { + const elem = el(`hs-ai-state-${s}`); + if (elem) { + elem.classList.toggle("hidden", s !== state); + } + } +} + +// ── IDE pill ────────────────────────────────────────────────────────────────── + +function movePill(btn: HTMLElement): void { + const pill = el("hs-ai-ide-pill"); + if (!pill) { return; } + pill.style.left = btn.offsetLeft + "px"; + pill.style.width = btn.offsetWidth + "px"; +} + +function initPill(): void { + const activeBtn = document.querySelector(".hs-ai-ide-btn.active"); + if (activeBtn) { movePill(activeBtn); } +} + +// ── Checklist rendering ─────────────────────────────────────────────────────── + +function renderSkillRows( + skills: SkillInfo[], + installedDirNames: string[] +): void { + const list = el("hs-ai-skills-list"); + if (!list) { return; } + const installedSet = new Set(installedDirNames); + list.innerHTML = skills + .map((s) => { + const isInstalled = installedSet.has(s.dirName); + const statusClass = isInstalled ? "hs-ai-item-status--ok" : "hs-ai-item-status--none"; + const statusText = isInstalled ? "installed" : "—"; + return ``; + }) + .join(""); + list.querySelectorAll(".hs-ai-cb").forEach((cb) => { + cb.addEventListener("change", updateApplyButton); + }); +} + +function renderMcpRows( + servers: McpServerInfo[], + configuredKeys: string[] +): void { + const list = el("hs-ai-mcp-list"); + if (!list) { return; } + const configuredSet = new Set(configuredKeys); + list.innerHTML = servers + .map((s) => { + const isConfigured = configuredSet.has(s.key); + const statusClass = isConfigured ? "hs-ai-item-status--ok" : "hs-ai-item-status--none"; + const statusText = isConfigured ? "configured" : "—"; + return ``; + }) + .join(""); + list.querySelectorAll(".hs-ai-cb").forEach((cb) => { + cb.addEventListener("change", updateApplyButton); + }); +} + +function updateApplyButton(): void { + const applyBtn = el("hs-ai-apply"); + if (!applyBtn) { return; } + const anyChecked = [...document.querySelectorAll(".hs-ai-cb")] + .some((c) => c.checked && !c.disabled); + applyBtn.disabled = !anyChecked; +} + +// ── Accordion toggle ────────────────────────────────────────────────────────── + +function toggleAccordion(): void { + _isOpen = !_isOpen; + + const panel = el("hs-ai-panel"); + const btn = el("hs-btn-ai-tools"); + const chevron = el("hs-ai-chevron"); + + panel.classList.toggle("open", _isOpen); + btn.classList.toggle("expanded", _isOpen); + btn.setAttribute("aria-expanded", String(_isOpen)); + chevron.classList.toggle("hs-chevron--open", _isOpen); + + if (_isOpen && !_dataFetched) { + _dataFetched = true; + showPanelState("loading"); + getVSCode()?.postMessage({ command: "aiToolsExpanded" }); + } + + if (_isOpen) { + // Re-position pill after layout settles (accordion may have just opened) + panel.addEventListener("transitionend", () => { + initPill(); + }, { once: true }); + } +} + +// ── Apply ───────────────────────────────────────────────────────────────────── + +function handleApply(): void { + if (!_cachedData) { return; } + + const skillCheckboxes = document.querySelectorAll(".hs-ai-cb[data-skill]"); + const mcpCheckboxes = document.querySelectorAll(".hs-ai-cb[data-mcp]"); + + const selectedSkills = [...skillCheckboxes] + .filter((c) => c.checked) + .map((c) => c.dataset.skill!); + + const selectedMcpKeys = [...mcpCheckboxes] + .filter((c) => c.checked) + .map((c) => c.dataset.mcp!); + + // Switch to applying visual: disable checkboxes and apply button + document.querySelectorAll(".hs-ai-cb").forEach((c) => { c.disabled = true; }); + const applyBtn = el("hs-ai-apply"); + if (applyBtn) { + applyBtn.disabled = true; + applyBtn.textContent = "Applying…"; + } + + getVSCode()?.postMessage({ + command: "installAiTools", + skills: selectedSkills, + ideTarget: _activeIde, + mcpServers: selectedMcpKeys, + }); +} + +// ── Message handling ────────────────────────────────────────────────────────── + +function handleAiToolsData(msg: AiToolsDataMessage): void { + if (msg.error) { + el("hs-ai-error-msg").textContent = msg.error; + showPanelState("error"); + return; + } + + _cachedData = { + skills: msg.skills, + installedByIde: msg.installedByIde, + mcpServers: msg.mcpServers, + configuredMcpKeys: msg.configuredMcpKeys, + detectedIde: msg.detectedIde, + }; + + // Set active IDE to detected editor + _activeIde = msg.detectedIde; + document.querySelectorAll(".hs-ai-ide-btn").forEach((btn) => { + const isActive = btn.dataset.ide === _activeIde; + btn.classList.toggle("active", isActive); + }); + + renderSkillRows(msg.skills, msg.installedByIde[_activeIde] ?? []); + renderMcpRows(msg.mcpServers, msg.configuredMcpKeys); + + showPanelState("ready"); + updateApplyButton(); + + requestAnimationFrame(() => { initPill(); }); +} + +function handleAiToolsProgress(msg: AiToolsProgressMessage): void { + // Find the row by data-skill or data-mcp attribute + const cb = document.querySelector( + `[data-skill="${msg.item}"], [data-mcp="${msg.item}"]` + ); + if (!cb) { return; } + + const row = cb.closest(".hs-ai-item"); + if (!row) { return; } + + // Remove existing tick if any + row.querySelector(".hs-ai-item-tick")?.remove(); + + const tick = document.createElement("span"); + tick.className = `hs-ai-item-tick hs-ai-item-tick--${msg.status === "done" ? "ok" : "err"}`; + tick.textContent = msg.status === "done" ? "✓" : "✕"; + row.appendChild(tick); +} + +function handleAiToolsResult(msg: AiToolsResultMessage): void { + // Force a re-fetch next time the panel opens so installed state is fresh + _dataFetched = false; + _cachedData = null; + + // Build done state: collect rows with ticks and show them + const doneSkillsDiv = el("hs-ai-done-skills-list"); + const doneMcpDiv = el("hs-ai-done-mcp-list"); + + if (doneSkillsDiv) { + const rows = document.querySelectorAll("#hs-ai-skills-list .hs-ai-item"); + doneSkillsDiv.innerHTML = ""; + rows.forEach((row) => { + const tick = row.querySelector(".hs-ai-item-tick"); + if (!tick) { return; } + const name = row.querySelector(".hs-ai-item-name")?.textContent ?? ""; + const isOk = tick.classList.contains("hs-ai-item-tick--ok"); + const statusClass = isOk ? "hs-ai-item-status--ok" : "hs-ai-item-status--none"; + const statusText = isOk ? "installed" : "error"; + doneSkillsDiv.insertAdjacentHTML( + "beforeend", + `
+ ${isOk ? "✓" : "✕"} + ${name} + ${statusText} +
` + ); + }); + } + + if (doneMcpDiv) { + const rows = document.querySelectorAll("#hs-ai-mcp-list .hs-ai-item"); + doneMcpDiv.innerHTML = ""; + rows.forEach((row) => { + const tick = row.querySelector(".hs-ai-item-tick"); + if (!tick) { return; } + const name = row.querySelector(".hs-ai-item-name")?.textContent ?? ""; + const isOk = tick.classList.contains("hs-ai-item-tick--ok"); + const statusClass = isOk ? "hs-ai-item-status--ok" : "hs-ai-item-status--none"; + const statusText = isOk ? "configured" : "error"; + doneMcpDiv.insertAdjacentHTML( + "beforeend", + `
+ ${isOk ? "✓" : "✕"} + ${name} + ${statusText} +
` + ); + }); + } + + showPanelState("done"); +} + +// ── Init ────────────────────────────────────────────────────────────────────── + +function init(): void { + initCommon(); + + // Standard action buttons (non-accordion) + document.querySelectorAll(".hs-action:not(#hs-btn-ai-tools)").forEach((btn) => { + btn.addEventListener("click", () => { + getVSCode()?.postMessage({ command: btn.dataset.command }); + }); + }); + + // Accordion toggle + el("hs-btn-ai-tools").addEventListener("click", toggleAccordion); + + // IDE selector buttons + document.querySelectorAll(".hs-ai-ide-btn").forEach((btn) => { + btn.addEventListener("click", () => { + if (!_cachedData) { return; } + document.querySelectorAll(".hs-ai-ide-btn").forEach((b) => b.classList.remove("active")); + btn.classList.add("active"); + movePill(btn); + _activeIde = btn.dataset.ide ?? "Claude Code"; + renderSkillRows(_cachedData.skills, _cachedData.installedByIde[_activeIde] ?? []); + updateApplyButton(); + }); + }); + + // Apply button + el("hs-ai-apply")?.addEventListener("click", handleApply); + + // Apply again button (done state) + el("hs-ai-apply-again")?.addEventListener("click", () => { + // Re-open: reset state and re-fetch + _isOpen = false; + toggleAccordion(); + }); + + // VS Code → webview messages + window.addEventListener("message", (event: MessageEvent) => { + const msg = event.data; + switch (msg.command) { + case "aiToolsData": + handleAiToolsData(msg as AiToolsDataMessage); + break; + case "aiToolsProgress": + handleAiToolsProgress(msg as AiToolsProgressMessage); + break; + case "aiToolsResult": + handleAiToolsResult(msg as AiToolsResultMessage); + break; + } + }); +} -document.addEventListener("DOMContentLoaded", () => { - document.getElementById("hs-btn-configure")?.addEventListener("click", () => postMessage("openGlobalConfig")); - document.getElementById("hs-btn-library")?.addEventListener("click", () => postMessage("showLibrary")); - document.getElementById("hs-btn-upload")?.addEventListener("click", () => postMessage("openUploadWidget")); - document.getElementById("hs-link-welcome")?.addEventListener("click", () => postMessage("openWelcomeScreen")); - document.getElementById("hs-btn-ai-tools")?.addEventListener("click", () => postMessage("configureAiTools")); -}); +document.addEventListener("DOMContentLoaded", init); From 3c5d378b3d1c4e4fa49142567a7b27ad9b412ea4 Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Thu, 2 Apr 2026 09:31:28 +0100 Subject: [PATCH 50/72] fix: add HTML escaping and null safety to homescreen client --- src/webview/client/homescreen.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/webview/client/homescreen.ts b/src/webview/client/homescreen.ts index 6241805..2d927d6 100644 --- a/src/webview/client/homescreen.ts +++ b/src/webview/client/homescreen.ts @@ -51,11 +51,19 @@ function el(id: string): T { } function show(id: string): void { - el(id).classList.remove("hidden"); + el(id)?.classList.remove("hidden"); } function hide(id: string): void { - el(id).classList.add("hidden"); + el(id)?.classList.add("hidden"); +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); } // ── State rendering ─────────────────────────────────────────────────────────── @@ -98,8 +106,8 @@ function renderSkillRows( const statusClass = isInstalled ? "hs-ai-item-status--ok" : "hs-ai-item-status--none"; const statusText = isInstalled ? "installed" : "—"; return ``; }) @@ -122,8 +130,8 @@ function renderMcpRows( const statusClass = isConfigured ? "hs-ai-item-status--ok" : "hs-ai-item-status--none"; const statusText = isConfigured ? "configured" : "—"; return ``; }) @@ -205,7 +213,8 @@ function handleApply(): void { function handleAiToolsData(msg: AiToolsDataMessage): void { if (msg.error) { - el("hs-ai-error-msg").textContent = msg.error; + const errEl = el("hs-ai-error-msg"); + if (errEl) { errEl.textContent = msg.error; } showPanelState("error"); return; } From 638c70c40daa9208fee91c93c6099a7d1a0c4c3a Mon Sep 17 00:00:00 2001 From: Nick Bradley Date: Thu, 2 Apr 2026 09:35:46 +0100 Subject: [PATCH 51/72] fix: add data-command attributes to action buttons and chevron open CSS --- src/webview/homescreenView.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/webview/homescreenView.ts b/src/webview/homescreenView.ts index 85ea9a2..74bcc04 100644 --- a/src/webview/homescreenView.ts +++ b/src/webview/homescreenView.ts @@ -356,7 +356,9 @@ export class HomescreenViewProvider implements vscode.WebviewViewProvider { flex-shrink: 0; color: var(--vscode-descriptionForeground); opacity: 0.4; + transition: transform 0.2s; } + .hs-chevron--open { transform: rotate(90deg); } .hs-section-divider { height: 1px; @@ -674,12 +676,12 @@ export class HomescreenViewProvider implements vscode.WebviewViewProvider {
Add your API credentials to connect - +
` : ""}
- -