mirror of
https://github.com/andrepimenta/claude-code-chat.git
synced 2026-06-14 10:22:13 +08:00
Compare commits
22 Commits
683148c4cf
...
v2.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64fd29c840 | ||
|
|
59519a619f | ||
|
|
10fa40c406 | ||
|
|
e73ab3bf0a | ||
|
|
6858f6a6c6 | ||
|
|
c11224310d | ||
|
|
cb5943eec5 | ||
|
|
3d8bcf5241 | ||
|
|
474910a330 | ||
|
|
58de99030c | ||
|
|
6d112012b2 | ||
|
|
e31c5357d2 | ||
|
|
b3ff6e9c03 | ||
|
|
05a15d19a6 | ||
|
|
f5d4c7851b | ||
|
|
ef0b3c1b4e | ||
|
|
deca7de8d5 | ||
|
|
b527b6f4c9 | ||
|
|
2f792e7158 | ||
|
|
97920395d1 | ||
|
|
2e640fa20a | ||
|
|
5136985474 |
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"frontend-design@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run compile:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(sed:*)",
|
||||
"Bash(rg:*)",
|
||||
"Bash(npx tsc:*)"
|
||||
],
|
||||
"deny": []
|
||||
},
|
||||
"enableAllProjectMcpServers": false
|
||||
}
|
||||
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: andrepimenta
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,4 +3,6 @@ dist
|
||||
node_modules
|
||||
.vscode-test/
|
||||
*.vsix
|
||||
backup
|
||||
backup
|
||||
backup-files
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -13,4 +13,7 @@ backup
|
||||
.claude
|
||||
claude-code-chat-permissions-mcp/**
|
||||
node_modules
|
||||
mcp-permissions.js
|
||||
mcp-permissions.js
|
||||
backup-files
|
||||
build
|
||||
**/test/**
|
||||
118
CHANGELOG.md
118
CHANGELOG.md
@@ -4,6 +4,124 @@ All notable changes to the "claude-code-chat" extension will be documented in th
|
||||
|
||||
Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file.
|
||||
|
||||
## [2.1.0] - 2026-06-07
|
||||
|
||||
### 🚀 Features Added
|
||||
- **Sponsor button**: Added a Sponsor button on both the GitHub repository (`.github/FUNDING.yml`) and the VS Code Marketplace listing (`sponsor` field in `package.json`), linking to GitHub Sponsors.
|
||||
- **GPT 5.5 as the default GPT model**: The "GPT" quick-select now targets OpenAI's mainline GPT 5.5, replacing the previous GPT 5.3 Codex entry, with a faster `gpt-5.4-mini` tier.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- **Valid fast-tier GPT fallback**: The bundled model fallback no longer points the GPT fast (haiku) tier at a non-existent `openai/gpt-5.5-mini`. It now uses `openai/gpt-5.4-mini`, matching what the live resolver selects, so the offline/fallback path can no longer hand out an invalid model id.
|
||||
|
||||
### 🔧 Technical Improvements
|
||||
- **OpenAI model resolver retargeted**: `model-updater.ts` now resolves the OpenAI quick-model against mainline `openai/gpt-<version>` and `openai/gpt-<version>-mini` slugs instead of the `-codex` / `-codex-mini` variants, so auto-update tracks the latest general GPT release.
|
||||
|
||||
## [2.0.7] - 2026-04-24
|
||||
|
||||
### 🚀 Features Added
|
||||
- **In-process installer**: Installing Claude Code no longer shells out to PowerShell, `curl | bash`, or `npm install -g`. The extension now fetches the platform-specific native binary directly (npm registry first, with Anthropic's CDN as a fallback), verifies the download with sha512/sha256, and writes it into the extension's own storage. Zero PATH, sudo, execution-policy, Node-version, or shell-quoting dependencies — if the extension installed, installing Claude works.
|
||||
- **Progress updates during install**: The install modal now reports "Looking up…", "Downloading… (X%)", "Verifying…", and "Installing…" as it runs, with an automatic retry message if it falls back to the CDN source.
|
||||
- **Cleaner install analytics**: `Install success` now includes `source` (npm/cdn) and `version`; `Install failed` now includes a typed `errorCode` (NETWORK / INTEGRITY / WRITE / AGGREGATE / UNSUPPORTED_PLATFORM) so failure buckets are meaningful instead of just "the shell command failed".
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- **Paths with spaces on Windows**: Fixed an edge case where the main Claude spawn could fail when the executable path contained spaces (e.g. `C:\Users\Some User\…`). Absolute paths now bypass `cmd.exe` wrapping entirely.
|
||||
|
||||
### 🔧 Technical Improvements
|
||||
- New `src/claudeDownloader.ts` module: self-contained, no new runtime dependencies. Includes a minimal in-tree tar parser so we can stream-extract the one binary we need from the npm tarball without bundling a tar library.
|
||||
- Removed the old PowerShell / curl / npm install paths and the associated `_getKnownInstallLocation` / `_checkClaudeAvailable` helpers — the download flow now owns the install location end-to-end.
|
||||
|
||||
## [2.0.6] - 2026-04-23
|
||||
|
||||
### 🚀 Features Added
|
||||
- **Smarter post-install setup**: Fresh installs now "just work" without a VS Code restart. After install, the extension checks whether `claude` resolved on your PATH and, if not, auto-configures `claudeCodeChat.executable.path` to the known install location. An existing custom executable path is respected.
|
||||
- **WSL: Node.js path is now optional**: Recent Claude Code ships as a native binary and doesn't need Node. Leave the **Node.js Path** field blank unless you installed Claude via npm. The WSL settings panel was also reordered so **Claude Path** comes first.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- **Rock-solid terminals across shells**: Login, Model, /usage, and slash-command terminals now launch Claude directly instead of sending text through the shell. Fixes a class of quoting issues on Windows PowerShell and keeps behavior identical across PowerShell, cmd, bash, and zsh.
|
||||
|
||||
### 🔧 Technical Improvements
|
||||
- Terminal sites now use `createTerminal`'s `shellPath`/`shellArgs` — no shell quoting, consistent env inheritance, identical behavior across OSes.
|
||||
|
||||
## [2.0.4] - 2026-04-21
|
||||
|
||||
### 🚀 Features Added
|
||||
- **Plan Mode (Improved)**:
|
||||
- Plans now render as beautifully formatted markdown with headings, lists, and code blocks
|
||||
- Suggested actions shown as clickable buttons below the plan (e.g. "run npm build")
|
||||
- Permission prompt says "Approve the plan above?" with an Approve button instead of the generic tool approval
|
||||
- **MCP, Skills & Plugins Marketplace**:
|
||||
- Browse 30+ curated MCP servers (GitHub, Slack, Stripe, Notion, Supabase, etc.)
|
||||
- Search across both add-mcp curated and official Anthropic registries with smart ranking
|
||||
- Install MCP servers to project (`.mcp.json`) or global (`~/.claude.json`)
|
||||
- Skills marketplace with one-click install via `npx skills add`
|
||||
- Plugins marketplace to extend Claude Code
|
||||
- OAuth authentication support — open terminal to log in to MCPs
|
||||
- **150+ AI Models via OpenCredits**:
|
||||
- Quick model switching: GPT, Gemini, MiniMax, Kimi, GLM, DeepSeek buttons above the text box
|
||||
- Browse and select from 150+ models across providers
|
||||
- Pay-as-you-go with OpenCredits — no subscription needed
|
||||
- US & EU provider filtering option in settings
|
||||
- Model selection persists correctly after checkout
|
||||
- **Image Preview**:
|
||||
- Paste or pick images with thumbnail preview before sending
|
||||
- Remove attached images before sending
|
||||
- Multiple image attachments per message
|
||||
- Image paths in text auto-detected and sent as base64
|
||||
- **Support & Feedback**:
|
||||
- "Support" button in status bar to send bug reports and feature requests
|
||||
- Submissions sent directly to Discord
|
||||
|
||||
### 🎨 UI Improvements
|
||||
- Inline stop button replaces send button during processing
|
||||
- Self-hosted Umami analytics with editor tracking (VS Code vs Cursor)
|
||||
- BETA badge on model section with instant tooltip
|
||||
- Cleaner model selector and Browse All Models alignment
|
||||
|
||||
### 🐛 Bug Fixes & Reliability
|
||||
- Fix model not being selected after OpenCredits checkout
|
||||
- Fix provider choice modal appearing unexpectedly after settings changes
|
||||
- Fix duplicate login error toast
|
||||
- Fix WSL environment variable passthrough for OpenCredits
|
||||
- Fix Windows URL opening with `start` command
|
||||
- Fix `--mcp-config` error on fresh installs
|
||||
- Await `setEnvsDisabled` so settings reflect changes immediately
|
||||
- Skip npx install prompt with `-y` flag for skills
|
||||
- Better install error messages (Node.js 18+ requirement)
|
||||
- Add node and mocha types to tsconfig for clean editor diagnostics
|
||||
- Remove debug `console.log`s, add `console.error` to empty catch blocks
|
||||
|
||||
## [1.1.0] - 2025-12-06
|
||||
|
||||
### 🚀 Features Added
|
||||
- **Install Modal**: Added installation flow for users without Claude Code CLI
|
||||
- Auto-detects when Claude Code is not installed
|
||||
- One-click installation with progress indicator
|
||||
- Platform-specific installation commands
|
||||
- **Diff Viewer Improvements**:
|
||||
- Show full diff in Edit, MultiEdit, and Write tool use messages
|
||||
- Add "Open Diff" button to open VS Code's native side-by-side diff editor
|
||||
- Add truncation with expand button for long diffs
|
||||
- Optimize diff storage and improve Open Diff button behavior
|
||||
- **Processing Indicator**: New morphing orange dot animation while Claude is working
|
||||
- **Subscription Detection**: Added usage badge to status bar showing plan type (Pro, Max) or API cost
|
||||
- **Conversation Compacting**: Handle `/compact` command in chat with status messages and token reset
|
||||
- **Permission System**: Migrated from MCP file-based to stdio-based permission prompts
|
||||
- **Plan Mode**: Now uses native `--permission-mode plan` CLI flag for cleaner implementation
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Fixed diff line alignment by removing ::before pseudo-elements
|
||||
- Fixed auto-scroll for diff tool results
|
||||
- Strip tool_use_error tags from error messages
|
||||
- Improved process termination handling
|
||||
|
||||
### 🔧 Technical Improvements
|
||||
- Run /compact command in chat instead of spawning terminal
|
||||
- Improved terminal and UI experience
|
||||
- Updated diff icon colors
|
||||
|
||||
### 📊 Analytics
|
||||
- Added Umami analytics events to track install flow (modal shown, started, success/failed)
|
||||
|
||||
## [1.0.7] - 2025-10-01
|
||||
|
||||
### 🚀 Features Added
|
||||
|
||||
89
README.md
89
README.md
@@ -15,13 +15,12 @@ Ditch the command line and experience Claude Code like never before. This extens
|
||||
## ✨ **Why Choose Claude Code Chat?**
|
||||
|
||||
🖥️ **No Terminal Required** - Beautiful chat interface replaces command-line interactions
|
||||
⏪ **Restore Checkpoints** - Undo changes and restore code to any previous state
|
||||
🔌 **MCP Server Support** - Complete Model Context Protocol server management
|
||||
⏪ **Restore Checkpoints** - Undo changes and restore code to any previous state
|
||||
🔌 **MCP, Skills & Plugins** - Browse, search, and install from curated marketplaces
|
||||
💾 **Conversation History** - Automatic conversation history and session management
|
||||
🎨 **VS Code Native** - Claude Code integrated directly into VS Code with native theming and sidebar support
|
||||
🧠 **Plan and Thinking modes** - Plan First and configurable Thinking modes for better results
|
||||
⚡ **Smart File/Image Context and Custom Commands** - Reference any file, paste images or screenshots and create custom commands
|
||||
🤖 **Model Selection** - Choose between Opus, Sonnet, or Default based on your needs
|
||||
🎨 **VS Code & Cursor** - Works in VS Code, Cursor, and other compatible editors
|
||||
🧠 **Plan and Ultrathink modes** - Plan First and Ultrathink modes
|
||||
⚡ **Smart Context** - Reference files, paste images, and create custom commands
|
||||
🐧 **Windows/WSL Support** - Full native Windows and WSL support
|
||||
|
||||

|
||||
@@ -47,28 +46,36 @@ Ditch the command line and experience Claude Code like never before. This extens
|
||||
- Real-time cost and token tracking
|
||||
- Session statistics and performance metrics
|
||||
|
||||
### 🔌 **MCP Server Management** ⭐ **NEW IN V1.0**
|
||||
- **Popular Servers Gallery** - One-click installation of common MCP servers
|
||||
- **Custom Server Creation** - Build and configure your own MCP servers
|
||||
- **Server Management** - Edit, delete, enable/disable servers through UI
|
||||
- **Automatic Integration** - Seamless permissions and tool integration
|
||||
- **Cross-platform Support** - Full WSL compatibility with path conversion
|
||||
### 📝 **Inline Diff Viewer**
|
||||
- **Full Diff Display** - See complete file changes directly in Edit, MultiEdit, and Write messages
|
||||
- **Open in VS Code Diff** - One-click button to open VS Code's native side-by-side diff editor
|
||||
- **Smart Truncation** - Long diffs are truncated with an expand button for better readability
|
||||
- **Syntax Highlighting** - Proper code highlighting in diff views
|
||||
- **Visual Change Indicators** - Clear green/red highlighting for additions and deletions
|
||||
|
||||
### 🔒 **Advanced Permissions System** ⭐ **NEW IN V1.0**
|
||||
### 🔌 **MCP, Skills & Plugins Marketplace** ⭐ **NEW IN V2.0**
|
||||
- **MCP Servers** - Browse 30+ featured servers (GitHub, Slack, Stripe, Notion, etc.) with dual registry search
|
||||
- **Skills** - Browse and install curated skills from skills.sh with project or global scope
|
||||
- **Plugins** - Browse and install plugins to extend Claude Code
|
||||
- **Smart Search** - Search across add-mcp curated and official Anthropic registries with relevance ranking
|
||||
- **Project or Global Install** - Install MCP servers to `.mcp.json` or `~/.claude.json`, skills to `.claude/skills/`
|
||||
- **One-Click Install** - Pre-filled configuration with env vars, headers, and OAuth authentication
|
||||
|
||||
### 🖼️ **Image Preview & Attachments** ⭐ **NEW IN V2.0**
|
||||
- **Paste Images** - Paste images with thumbnail preview before sending
|
||||
- **File Picker** - Select images through VS Code's native file picker
|
||||
- **Preview Strip** - See attached images above the text box with remove buttons
|
||||
- **Inline Detection** - Image paths in messages are automatically detected and sent as base64
|
||||
- **Multiple Images** - Attach multiple images to a single message
|
||||
|
||||
### 🔒 **Advanced Permissions System**
|
||||
- **Interactive Permission Dialogs** - Detailed tool information with command previews
|
||||
- **Always Allow Functionality** - Smart command pattern matching for common tools (npm, git, docker)
|
||||
- **YOLO Mode** - Skip all permission checks for power users
|
||||
- **Workspace Permissions** - Granular control over what tools can execute
|
||||
- **Real-time Permission Management** - Add/remove permissions through intuitive UI
|
||||
|
||||
### 🖼️ **Image & Clipboard Support** ⭐ **NEW IN V1.0**
|
||||
- **Drag & Drop Images** - Simply drag images directly into the chat
|
||||
- **Clipboard Paste** - Press Ctrl+V to paste screenshots and copied images
|
||||
- **Multiple Image Selection** - Choose multiple images through VS Code's file picker
|
||||
- **Organized Storage** - Automatic organization in `.claude/claude-code-chat-images/`
|
||||
- **Format Support** - PNG, JPG, JPEG, GIF, SVG, WebP, BMP formats
|
||||
|
||||
### 📱 **Sidebar Integration** ⭐ **NEW IN V1.0**
|
||||
### 📱 **Sidebar Integration**
|
||||
- **Native VS Code Sidebar** - Full chat functionality in the sidebar panel
|
||||
- **Smart Panel Management** - Automatic switching between main and sidebar views
|
||||
- **Persistent Sessions** - State maintained across panel switches
|
||||
@@ -76,30 +83,30 @@ Ditch the command line and experience Claude Code like never before. This extens
|
||||
|
||||
### 📁 **Smart File Integration**
|
||||
- Type `@` to instantly search and reference workspace files
|
||||
- Image attachments via file browser and copy-paste screeshots
|
||||
- Image attachments via file browser and copy-paste screenshots
|
||||
- Lightning-fast file search across your entire project
|
||||
- Seamless context preservation for multi-file discussions
|
||||
|
||||
### 🛠️ **Tool Management**
|
||||
- Visual dashboard showing all available Claude Code tools
|
||||
- Real-time tool execution with formatted results
|
||||
- Process control - start, stop, and monitor operations
|
||||
- Inline stop button replaces send during processing
|
||||
- Smart permission system for secure tool execution
|
||||
|
||||
### 🎨 **VS Code Integration**
|
||||
- Native theming that matches your editor
|
||||
- Status bar integration with connection status
|
||||
- Status bar with support button
|
||||
- Activity bar panel for quick access
|
||||
- Responsive design for any screen size
|
||||
|
||||
### 🤖 **Model Selection**
|
||||
- **Quick Buttons** - GPT, Gemini, MiniMax, Kimi, GLM, DeepSeek above the text box
|
||||
- **Opus** - Most capable model for complex tasks requiring deep reasoning
|
||||
- **Sonnet** - Balanced model offering great performance for most use cases
|
||||
- **Default** - Uses your configured model setting
|
||||
- Model preference persists across sessions and is saved automatically
|
||||
- Easy switching via dropdown selector in the chat interface
|
||||
- Visual confirmation when switching between models
|
||||
- One-click model configuration through integrated terminal
|
||||
- **150+ OpenCredits Models** - Browse and switch to any available model
|
||||
- Model preference persists across sessions
|
||||
- Provider choice (OpenCredits vs Anthropic) for Claude models
|
||||
|
||||
### ⚡ **Slash Commands Integration**
|
||||
- **Slash Commands Modal** - Type "/" to access all Claude Code commands instantly
|
||||
@@ -115,14 +122,20 @@ Ditch the command line and experience Claude Code like never before. This extens
|
||||
- **Intelligent Prompting** - Different prompts based on selected thinking intensity
|
||||
- **Token Awareness** - Higher thinking levels consume more tokens but provide deeper reasoning
|
||||
|
||||
### 💬 **Support & Feedback** ⭐ **NEW IN V2.0**
|
||||
- **In-App Support** - Click "Support" in the status bar to send feedback
|
||||
- **Bug Reports & Feature Requests** - Submit directly from the extension
|
||||
- **Optional Email** - Include your email for follow-up
|
||||
|
||||
---
|
||||
|
||||
## 🚀 **Getting Started**
|
||||
|
||||
### Prerequisites
|
||||
- **VS Code 1.80+** - Latest version recommended
|
||||
- **VS Code 1.80+** or **Cursor** - Latest version recommended
|
||||
- **Claude Code CLI** - [Install from Anthropic](https://claude.ai/code)
|
||||
- **Active Claude API or subscription** - API or Pro/Max plan
|
||||
- **Node.js 18+** - Required for installation
|
||||
|
||||
### Installation
|
||||
|
||||
@@ -244,25 +257,26 @@ Example configuration in `settings.json`:
|
||||
- Type `@` followed by your search term to quickly reference files
|
||||
- Use `@src/` to narrow down to specific directories
|
||||
- Reference multiple files in one message for cross-file analysis
|
||||
- **NEW**: Copy-paste images directly into chat for visual context
|
||||
- **NEW**: Paste screenshots with Ctrl+V for instant visual communication
|
||||
- Paste images directly with preview thumbnails before sending
|
||||
- Paste screenshots with Ctrl+V for instant visual communication
|
||||
|
||||
### ⚡ **Productivity Boosters**
|
||||
- **Creates checkpoints automatically** before changes for safe experimentation
|
||||
- **Restore instantly** if changes don't work out as expected
|
||||
- **NEW**: Permission system prevents accidental tool execution
|
||||
- **NEW**: YOLO mode for power users who want speed over safety
|
||||
- Use the stop button to cancel long-running operations
|
||||
- Permission system prevents accidental tool execution
|
||||
- YOLO mode for power users who want speed over safety
|
||||
- Inline stop button to cancel long-running operations
|
||||
- Copy message contents to reuse Claude's responses
|
||||
- Open history panel to reference previous conversations
|
||||
- **NEW**: Sidebar integration for multi-panel workflow
|
||||
- Sidebar integration for multi-panel workflow
|
||||
- **Plan mode** and **Ultrathink** toggles above the text box
|
||||
|
||||
### 🎨 **Interface Customization**
|
||||
- The UI automatically adapts to your VS Code theme
|
||||
- Messages are color-coded: Green for you, Blue for Claude
|
||||
- Hover over messages to reveal the copy button
|
||||
- **NEW**: Enhanced code block rendering with syntax highlighting
|
||||
- **NEW**: Copy-to-clipboard functionality for code blocks
|
||||
- Enhanced code block rendering with syntax highlighting
|
||||
- Copy-to-clipboard functionality for code blocks
|
||||
|
||||
---
|
||||
|
||||
@@ -334,6 +348,7 @@ See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
Need help? We've got you covered:
|
||||
|
||||
- 💬 **In-App** - Click "Support" in the status bar to send feedback directly
|
||||
- 🐛 **Issues**: [GitHub Issues](https://github.com/andrepimenta/claude-code-chat/issues)
|
||||
|
||||
---
|
||||
|
||||
21
backup.sh
Executable file
21
backup.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Backup script for src folder
|
||||
|
||||
# Get the directory where the script is located
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Create backup directory if it doesn't exist
|
||||
BACKUP_DIR="$SCRIPT_DIR/backup-files"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Generate timestamp
|
||||
TIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S")
|
||||
|
||||
# Create backup filename
|
||||
BACKUP_NAME="src-backup-$TIMESTAMP"
|
||||
|
||||
# Copy src folder to backup
|
||||
cp -r "$SCRIPT_DIR/src" "$BACKUP_DIR/$BACKUP_NAME"
|
||||
|
||||
echo "Backup created: $BACKUP_DIR/$BACKUP_NAME"
|
||||
71
build/open-vsix/build.sh
Executable file
71
build/open-vsix/build.sh
Executable file
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Build script for Open VSIX version
|
||||
# This applies Open VSIX-specific changes, builds the package, then reverts
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERSION="2.1.0"
|
||||
OUTPUT_NAME="vsix-claude-code-chat-${VERSION}.vsix"
|
||||
|
||||
echo "Building Open VSIX version ${VERSION}..."
|
||||
|
||||
# Backup original files to build folder
|
||||
cp package.json "${SCRIPT_DIR}/package.json.backup"
|
||||
cp src/extension.ts "${SCRIPT_DIR}/extension.ts.backup"
|
||||
|
||||
# Backup original icon.png if it exists
|
||||
if [ -f "icon.png" ]; then
|
||||
mv icon.png "${SCRIPT_DIR}/icon.png.backup"
|
||||
fi
|
||||
|
||||
# Copy Open VSIX icon
|
||||
cp "${SCRIPT_DIR}/icon.png" icon.png
|
||||
echo "Copied Open VSIX icon"
|
||||
|
||||
# Temporarily remove icon-bubble.png (not needed for Open VSIX)
|
||||
if [ -f "icon-bubble.png" ]; then
|
||||
mv icon-bubble.png "${SCRIPT_DIR}/icon-bubble.png.backup"
|
||||
fi
|
||||
|
||||
# Apply Open VSIX changes to package.json
|
||||
sed -i.bak 's/"displayName": "Chat for Claude Code"/"displayName": "Claude Code Chat"/' package.json
|
||||
sed -i.bak 's/"icon": "icon-bubble.png"/"icon": "icon.png"/g' package.json
|
||||
rm -f package.json.bak
|
||||
|
||||
# Apply Open VSIX changes to extension.ts
|
||||
sed -i.bak "s/icon-bubble.png/icon.png/g" src/extension.ts
|
||||
rm -f src/extension.ts.bak
|
||||
|
||||
echo "Applied Open VSIX changes to package.json and extension.ts"
|
||||
|
||||
# Compile TypeScript
|
||||
echo "Compiling TypeScript..."
|
||||
npm run compile
|
||||
|
||||
# Build the VSIX
|
||||
echo "Building VSIX package..."
|
||||
vsce package --out "${OUTPUT_NAME}"
|
||||
|
||||
# Restore original files from build folder
|
||||
mv "${SCRIPT_DIR}/package.json.backup" package.json
|
||||
mv "${SCRIPT_DIR}/extension.ts.backup" src/extension.ts
|
||||
|
||||
# Restore original icon
|
||||
rm -f icon.png
|
||||
if [ -f "${SCRIPT_DIR}/icon.png.backup" ]; then
|
||||
mv "${SCRIPT_DIR}/icon.png.backup" icon.png
|
||||
fi
|
||||
|
||||
# Restore icon-bubble.png
|
||||
if [ -f "${SCRIPT_DIR}/icon-bubble.png.backup" ]; then
|
||||
mv "${SCRIPT_DIR}/icon-bubble.png.backup" icon-bubble.png
|
||||
fi
|
||||
|
||||
# Recompile with original extension.ts
|
||||
echo "Recompiling with original files..."
|
||||
npm run compile
|
||||
|
||||
echo "Restored original files"
|
||||
echo "Built: ${OUTPUT_NAME}"
|
||||
BIN
build/open-vsix/icon.png
Normal file
BIN
build/open-vsix/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 689 KiB |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-code-chat",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-code-chat",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.8",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"devDependencies": {
|
||||
"@types/mocha": "^10.0.10",
|
||||
|
||||
33
package.json
33
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "claude-code-chat",
|
||||
"displayName": "Chat for Claude Code",
|
||||
"description": "Beautiful Claude Code Chat Interface for VS Code",
|
||||
"version": "1.0.7",
|
||||
"version": "2.1.0",
|
||||
"publisher": "AndrePimenta",
|
||||
"author": "Andre Pimenta",
|
||||
"repository": {
|
||||
@@ -10,6 +10,9 @@
|
||||
"url": "https://github.com/andrepimenta/claude-code-chat"
|
||||
},
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"sponsor": {
|
||||
"url": "https://github.com/sponsors/andrepimenta"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.94.0"
|
||||
},
|
||||
@@ -162,8 +165,8 @@
|
||||
},
|
||||
"claudeCodeChat.wsl.nodePath": {
|
||||
"type": "string",
|
||||
"default": "/usr/bin/node",
|
||||
"description": "Path to Node.js in the WSL distribution"
|
||||
"default": "",
|
||||
"description": "Optional path to Node.js in the WSL distribution. Only needed if Claude was installed via npm. Recent Claude installs ship as a native executable and don't require Node."
|
||||
},
|
||||
"claudeCodeChat.wsl.claudePath": {
|
||||
"type": "string",
|
||||
@@ -185,6 +188,26 @@
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable Yolo Mode to skip all permission checks. Use with caution as Claude can execute any command without asking."
|
||||
},
|
||||
"claudeCodeChat.executable.path": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Custom path to the Claude Code executable. Leave empty to use the default 'claude' command."
|
||||
},
|
||||
"claudeCodeChat.environment.variables": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"description": "Custom environment variables to pass to Claude Code. Example: {\"ANTHROPIC_API_KEY\": \"sk-...\"}"
|
||||
},
|
||||
"claudeCodeChat.environment.disabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "When enabled, custom environment variables are not passed to Claude Code."
|
||||
},
|
||||
"claudeCodeChat.router.enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable the local router to convert OpenAI format to Anthropic format. Required for providers that use OpenAI-compatible APIs."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,7 +218,9 @@
|
||||
"watch": "tsc -watch -p ./",
|
||||
"pretest": "npm run compile && npm run lint",
|
||||
"lint": "eslint src",
|
||||
"test": "vscode-test"
|
||||
"test": "vscode-test",
|
||||
"test:downloader": "npm run compile && mocha --ui tdd \"out/test/downloader*.test.js\" --reporter spec --timeout 360000",
|
||||
"test:downloader:unit": "npm run compile && mocha --ui tdd out/test/downloader.test.js --reporter spec"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mocha": "^10.0.10",
|
||||
|
||||
662
src/claudeDownloader.ts
Normal file
662
src/claudeDownloader.ts
Normal file
@@ -0,0 +1,662 @@
|
||||
// Self-contained downloader for the Claude Code native binary.
|
||||
// Tries the npm registry tarball first (smaller over the wire thanks to gzip),
|
||||
// falls back to Anthropic's CDN (downloads.claude.ai) on any npm failure.
|
||||
//
|
||||
// Replaces the previous shell-based install flows (curl|bash, irm|iex, npm -g)
|
||||
// so users never see execution-policy, EACCES, missing-bash, or Node-version
|
||||
// failure modes. Everything runs in-process using Node built-ins only.
|
||||
|
||||
import * as https from 'https';
|
||||
import * as http from 'http';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import * as zlib from 'zlib';
|
||||
import * as os from 'os';
|
||||
import * as cp from 'child_process';
|
||||
import { URL } from 'url';
|
||||
|
||||
const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org';
|
||||
const DEFAULT_CDN_BASE = 'https://downloads.claude.ai/claude-code-releases';
|
||||
const NPM_PACKAGE_PREFIX = '@anthropic-ai/claude-code-';
|
||||
const META_TIMEOUT_MS = 30_000;
|
||||
const PROGRESS_THROTTLE_MS = 250;
|
||||
|
||||
export type DownloaderErrorCode =
|
||||
| 'UNSUPPORTED_PLATFORM'
|
||||
| 'NETWORK'
|
||||
| 'INTEGRITY'
|
||||
| 'WRITE'
|
||||
| 'CANCELLED'
|
||||
| 'AGGREGATE';
|
||||
|
||||
export interface PlatformKey {
|
||||
key: string; // 'darwin-arm64' | 'linux-x64-musl' | 'win32-x64' | ...
|
||||
binaryName: string; // 'claude' | 'claude.exe'
|
||||
tarEntry: string; // 'package/claude' | 'package/claude.exe'
|
||||
}
|
||||
|
||||
export interface DownloadProgress {
|
||||
phase: 'resolving' | 'downloading' | 'verifying' | 'installing' | 'fallback';
|
||||
source?: 'npm' | 'cdn';
|
||||
loaded?: number;
|
||||
total?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface DownloadOptions {
|
||||
destDir: string;
|
||||
onProgress?: (p: DownloadProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
/** @internal — override the npm registry base (for tests). */
|
||||
npmRegistry?: string;
|
||||
/** @internal — override the Anthropic CDN base (for tests). */
|
||||
cdnBase?: string;
|
||||
}
|
||||
|
||||
export interface DownloadResult {
|
||||
binaryPath: string;
|
||||
version: string;
|
||||
source: 'npm' | 'cdn';
|
||||
bytesDownloaded: number;
|
||||
}
|
||||
|
||||
export class DownloaderError extends Error {
|
||||
public readonly code: DownloaderErrorCode;
|
||||
public readonly details?: Record<string, string | number>;
|
||||
public readonly cause?: unknown;
|
||||
|
||||
constructor(code: DownloaderErrorCode, message: string, details?: Record<string, string | number>, cause?: unknown) {
|
||||
super(message);
|
||||
this.name = 'DownloaderError';
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the OS-level error code (EACCES/EBUSY/ENOSPC/ENOTFOUND/etc.) from an
|
||||
// arbitrary error, falling back to a short constant. We never inline err.message
|
||||
// into DownloaderError.message because Node's fs errors interpolate the offending
|
||||
// path — e.g. "EACCES: permission denied, open '/Users/<name>/Library/...'" —
|
||||
// which would exfiltrate the user's home directory in analytics.
|
||||
function _errCode(err: unknown, fallback: string): string {
|
||||
if (err && typeof err === 'object') {
|
||||
const c = (err as { code?: unknown }).code;
|
||||
if (typeof c === 'string' && c) {return c;}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Invoke the caller's onProgress callback without letting a user throw crash
|
||||
// the download stream. Throws inside a stream 'data' handler otherwise surface
|
||||
// as uncaughtException on the extension host.
|
||||
function _safeProgress(cb: ((p: DownloadProgress) => void) | undefined, p: DownloadProgress): void {
|
||||
if (!cb) {return;}
|
||||
try {
|
||||
cb(p);
|
||||
} catch {
|
||||
// swallow — progress reporting is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// ------------- Platform detection -------------
|
||||
|
||||
export function detectPlatform(): PlatformKey | null {
|
||||
const platform = process.platform;
|
||||
let arch = os.arch();
|
||||
|
||||
if (platform === 'darwin') {
|
||||
// Rosetta 2: x64 Node on Apple Silicon should use the arm64 binary —
|
||||
// the x64 build needs AVX which Rosetta doesn't emulate.
|
||||
if (arch === 'x64') {
|
||||
try {
|
||||
const r = cp.spawnSync('sysctl', ['-n', 'sysctl.proc_translated'], { encoding: 'utf8' });
|
||||
if (r.stdout && r.stdout.trim() === '1') {
|
||||
arch = 'arm64';
|
||||
}
|
||||
} catch {
|
||||
// sysctl missing — treat as non-Rosetta
|
||||
}
|
||||
}
|
||||
if (arch !== 'x64' && arch !== 'arm64') {return null;}
|
||||
return { key: `darwin-${arch}`, binaryName: 'claude', tarEntry: 'package/claude' };
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
if (arch !== 'x64' && arch !== 'arm64') {return null;}
|
||||
const musl = _detectMusl();
|
||||
const key = `linux-${arch}${musl ? '-musl' : ''}`;
|
||||
return { key, binaryName: 'claude', tarEntry: 'package/claude' };
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
if (arch !== 'x64' && arch !== 'arm64') {return null;}
|
||||
return { key: `win32-${arch}`, binaryName: 'claude.exe', tarEntry: 'package/claude.exe' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function _detectMusl(): boolean {
|
||||
try {
|
||||
const report = (process as unknown as { report?: { getReport?: () => { header?: { glibcVersionRuntime?: string } } } }).report;
|
||||
if (report && typeof report.getReport === 'function') {
|
||||
const r = report.getReport();
|
||||
return !r.header?.glibcVersionRuntime;
|
||||
}
|
||||
} catch {
|
||||
// fall through to file-presence check
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync('/lib/libc.musl-x86_64.so.1') || fs.existsSync('/lib/libc.musl-aarch64.so.1')) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ------------- HTTP helpers -------------
|
||||
|
||||
function _checkAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new DownloaderError('CANCELLED', 'Cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
function _httpGet(urlStr: string, signal?: AbortSignal, redirectsRemaining = 5): Promise<http.IncomingMessage> {
|
||||
return new Promise((resolve, reject) => {
|
||||
_checkAborted(signal);
|
||||
const parsed = new URL(urlStr);
|
||||
// Pick http or https by scheme so tests can target a local http server.
|
||||
const getter = parsed.protocol === 'http:' ? http.get : https.get;
|
||||
const req = getter(urlStr, { headers: { 'user-agent': 'claude-code-chat-vscode' } }, (res) => {
|
||||
const status = res.statusCode ?? 0;
|
||||
if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) {
|
||||
res.resume();
|
||||
if (redirectsRemaining <= 0) {
|
||||
reject(new DownloaderError('NETWORK', 'Too many redirects', { host: parsed.host }));
|
||||
return;
|
||||
}
|
||||
const next = new URL(res.headers.location, urlStr).toString();
|
||||
_httpGet(next, signal, redirectsRemaining - 1).then(resolve, reject);
|
||||
return;
|
||||
}
|
||||
if (status < 200 || status >= 300) {
|
||||
res.resume();
|
||||
reject(new DownloaderError('NETWORK', `HTTP ${status} from ${parsed.host}`, { status, host: parsed.host }));
|
||||
return;
|
||||
}
|
||||
resolve(res);
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
const code = _errCode(err, 'NETERR');
|
||||
reject(new DownloaderError('NETWORK', `Network error (${code}) from ${parsed.host}`, { host: parsed.host, code }, err));
|
||||
});
|
||||
const onAbort = () => {
|
||||
req.destroy();
|
||||
reject(new DownloaderError('CANCELLED', 'Cancelled'));
|
||||
};
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function _fetchBuffer(urlStr: string, signal?: AbortSignal): Promise<Buffer> {
|
||||
const res = await _httpGet(urlStr, signal);
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
const timer = setTimeout(() => {
|
||||
res.destroy();
|
||||
reject(new DownloaderError('NETWORK', 'Metadata request timed out'));
|
||||
}, META_TIMEOUT_MS);
|
||||
res.on('data', (c: Buffer) => chunks.push(c));
|
||||
res.on('end', () => { clearTimeout(timer); resolve(Buffer.concat(chunks)); });
|
||||
res.on('error', (err) => { clearTimeout(timer); reject(new DownloaderError('NETWORK', `Response error (${_errCode(err, 'NETERR')})`, { code: _errCode(err, 'NETERR') }, err)); });
|
||||
});
|
||||
}
|
||||
|
||||
async function _fetchText(url: string, signal?: AbortSignal): Promise<string> {
|
||||
return (await _fetchBuffer(url, signal)).toString('utf8');
|
||||
}
|
||||
|
||||
async function _fetchJson<T = unknown>(url: string, signal?: AbortSignal): Promise<T> {
|
||||
const body = await _fetchText(url, signal);
|
||||
try {
|
||||
return JSON.parse(body) as T;
|
||||
} catch (err) {
|
||||
throw new DownloaderError('NETWORK', 'Invalid JSON in response', undefined, err);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------- Tar extraction (minimal, ustar-only) -------------
|
||||
//
|
||||
// Extracts a single file by name from a gunzipped tar stream. Npm-published
|
||||
// tarballs use plain ustar with short filenames, so we don't handle GNU long-
|
||||
// link extensions, PAX headers, or sparse files. If the target entry isn't
|
||||
// found by end of stream, we throw INTEGRITY — the tarball shape is wrong.
|
||||
|
||||
function _parseOctal(buf: Buffer): number {
|
||||
// Octal ASCII, null/space terminated.
|
||||
let end = 0;
|
||||
while (end < buf.length && buf[end] !== 0 && buf[end] !== 0x20) {end++;}
|
||||
const s = buf.subarray(0, end).toString('ascii').trim();
|
||||
return s.length ? parseInt(s, 8) : 0;
|
||||
}
|
||||
|
||||
function _readTarHeader(block: Buffer): { name: string; size: number; isRegularFile: boolean } {
|
||||
const name = block.subarray(0, 100).toString('utf8').replace(/\0+$/, '');
|
||||
const prefix = block.subarray(345, 500).toString('utf8').replace(/\0+$/, '');
|
||||
const rawSize = _parseOctal(block.subarray(124, 136));
|
||||
// Defensive: guard against NaN / negative / non-finite sizes from malformed
|
||||
// tarballs before they poison our skip-byte arithmetic downstream.
|
||||
const size = Number.isFinite(rawSize) && rawSize >= 0 ? rawSize : -1;
|
||||
const typeFlag = String.fromCharCode(block[156] || 0);
|
||||
const isRegularFile = typeFlag === '0' || typeFlag === '\0';
|
||||
const fullName = prefix ? `${prefix}/${name}` : name;
|
||||
return { name: fullName, size, isRegularFile };
|
||||
}
|
||||
|
||||
interface TarExtractState {
|
||||
found: boolean;
|
||||
bytesWritten: number;
|
||||
buffer: Buffer;
|
||||
// When >0, we are in the middle of the target file's data, and this many
|
||||
// bytes still need to be written to out.
|
||||
remainingFileBytes: number;
|
||||
// When >0, we are skipping past a non-target file's data+padding.
|
||||
remainingSkipBytes: number;
|
||||
}
|
||||
|
||||
function _processTarChunk(state: TarExtractState, chunk: Buffer, entryName: string, out: fs.WriteStream): void {
|
||||
state.buffer = state.buffer.length ? Buffer.concat([state.buffer, chunk]) : chunk;
|
||||
|
||||
while (true) {
|
||||
if (state.remainingFileBytes > 0) {
|
||||
const take = Math.min(state.remainingFileBytes, state.buffer.length);
|
||||
if (take === 0) {return;}
|
||||
out.write(state.buffer.subarray(0, take));
|
||||
state.bytesWritten += take;
|
||||
state.remainingFileBytes -= take;
|
||||
state.buffer = state.buffer.subarray(take);
|
||||
if (state.remainingFileBytes === 0) {
|
||||
// After the file data, skip the 512-byte padding tail.
|
||||
const padLen = (512 - (state.bytesWritten % 512)) % 512;
|
||||
state.remainingSkipBytes = padLen;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state.remainingSkipBytes > 0) {
|
||||
const skip = Math.min(state.remainingSkipBytes, state.buffer.length);
|
||||
if (skip === 0) {return;}
|
||||
state.remainingSkipBytes -= skip;
|
||||
state.buffer = state.buffer.subarray(skip);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state.buffer.length < 512) {return;}
|
||||
|
||||
const header = state.buffer.subarray(0, 512);
|
||||
// End-of-archive is two consecutive zero-blocks. A single zero-block
|
||||
// also terminates our scan safely.
|
||||
if (header[0] === 0) {return;}
|
||||
|
||||
const { name, size, isRegularFile } = _readTarHeader(header);
|
||||
state.buffer = state.buffer.subarray(512);
|
||||
|
||||
// Size < 0 means the header was malformed (NaN / negative octal). Bail so
|
||||
// we don't poison the skip arithmetic — the outer INTEGRITY check will fire.
|
||||
if (size < 0) {throw new DownloaderError('INTEGRITY', 'Malformed tar header (invalid size)');}
|
||||
|
||||
if (name === entryName && isRegularFile) {
|
||||
state.found = true;
|
||||
state.remainingFileBytes = size;
|
||||
state.bytesWritten = 0;
|
||||
} else {
|
||||
// Skip this file's data + padding.
|
||||
const padded = Math.ceil(size / 512) * 512;
|
||||
state.remainingSkipBytes = padded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------- npm source -------------
|
||||
|
||||
interface NpmPackageMetadata {
|
||||
'dist-tags': { latest: string; [tag: string]: string };
|
||||
versions: Record<string, { dist: { tarball: string; integrity: string } }>;
|
||||
}
|
||||
|
||||
async function _downloadFromNpm(platform: PlatformKey, opts: DownloadOptions): Promise<DownloadResult> {
|
||||
const onProgress = opts.onProgress;
|
||||
const registry = opts.npmRegistry || DEFAULT_NPM_REGISTRY;
|
||||
_safeProgress(onProgress, { phase: 'resolving', source: 'npm', message: 'Looking up latest version' });
|
||||
|
||||
const metaUrl = `${registry}/${NPM_PACKAGE_PREFIX}${platform.key}`;
|
||||
const meta = await _fetchJson<NpmPackageMetadata>(metaUrl, opts.signal);
|
||||
const version = meta['dist-tags']?.latest;
|
||||
if (!version) {throw new DownloaderError('NETWORK', 'npm metadata missing dist-tags.latest');}
|
||||
const versionMeta = meta.versions?.[version];
|
||||
if (!versionMeta?.dist?.tarball || !versionMeta.dist.integrity) {
|
||||
throw new DownloaderError('NETWORK', 'npm metadata missing tarball or integrity');
|
||||
}
|
||||
const tarballUrl = versionMeta.dist.tarball;
|
||||
const integrity = versionMeta.dist.integrity;
|
||||
const dashIdx = integrity.indexOf('-');
|
||||
if (dashIdx < 0) {throw new DownloaderError('INTEGRITY', 'Unrecognized integrity format');}
|
||||
const algo = integrity.slice(0, dashIdx);
|
||||
const expectedB64 = integrity.slice(dashIdx + 1);
|
||||
if (!['sha256', 'sha384', 'sha512'].includes(algo)) {
|
||||
throw new DownloaderError('INTEGRITY', `Unsupported hash algorithm: ${algo}`, { algo });
|
||||
}
|
||||
|
||||
const tempPath = path.join(opts.destDir, `.claude.download.${process.pid}.${Date.now()}`);
|
||||
const writeStream = fs.createWriteStream(tempPath);
|
||||
const hash = crypto.createHash(algo);
|
||||
const gunzip = zlib.createGunzip();
|
||||
|
||||
const state: TarExtractState = {
|
||||
found: false,
|
||||
bytesWritten: 0,
|
||||
buffer: Buffer.alloc(0),
|
||||
remainingFileBytes: 0,
|
||||
remainingSkipBytes: 0,
|
||||
};
|
||||
|
||||
_safeProgress(onProgress, { phase: 'downloading', source: 'npm', loaded: 0 });
|
||||
|
||||
let res: http.IncomingMessage;
|
||||
try {
|
||||
res = await _httpGet(tarballUrl, opts.signal);
|
||||
} catch (err) {
|
||||
writeStream.destroy();
|
||||
await _safeUnlink(tempPath);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const total = Number(res.headers['content-length']) || undefined;
|
||||
let bytesDownloaded = 0;
|
||||
let lastProgressAt = 0;
|
||||
|
||||
const extractPromise = new Promise<void>((resolve, reject) => {
|
||||
gunzip.on('data', (chunk: Buffer) => {
|
||||
try {
|
||||
_processTarChunk(state, chunk, platform.tarEntry, writeStream);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
gunzip.on('end', () => resolve());
|
||||
gunzip.on('error', (err) => reject(new DownloaderError('INTEGRITY', 'Tarball decompression failed', undefined, err)));
|
||||
});
|
||||
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
bytesDownloaded += chunk.length;
|
||||
hash.update(chunk);
|
||||
const now = Date.now();
|
||||
if (now - lastProgressAt > PROGRESS_THROTTLE_MS) {
|
||||
lastProgressAt = now;
|
||||
_safeProgress(onProgress, { phase: 'downloading', source: 'npm', loaded: bytesDownloaded, total });
|
||||
}
|
||||
});
|
||||
|
||||
const responseDone = new Promise<void>((resolve, reject) => {
|
||||
res.on('end', () => resolve());
|
||||
res.on('error', (err) => reject(new DownloaderError('NETWORK', `Response stream error (${_errCode(err, 'NETERR')})`, { code: _errCode(err, 'NETERR') }, err)));
|
||||
});
|
||||
|
||||
const writeDone = new Promise<void>((resolve, reject) => {
|
||||
writeStream.on('close', () => resolve());
|
||||
writeStream.on('error', (err) => reject(new DownloaderError('WRITE', `Write failed (${_errCode(err, 'WRITEERR')})`, { code: _errCode(err, 'WRITEERR') }, err)));
|
||||
});
|
||||
|
||||
const onAbort = () => {
|
||||
res.destroy();
|
||||
gunzip.destroy();
|
||||
writeStream.destroy();
|
||||
};
|
||||
opts.signal?.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
res.pipe(gunzip);
|
||||
|
||||
try {
|
||||
await Promise.all([responseDone, extractPromise]);
|
||||
writeStream.end();
|
||||
await writeDone;
|
||||
} catch (err) {
|
||||
// Tear down both ends explicitly — leaving res piping after an extract
|
||||
// failure would leak bandwidth and memory.
|
||||
res.destroy();
|
||||
gunzip.destroy();
|
||||
writeStream.destroy();
|
||||
await _safeUnlink(tempPath);
|
||||
if (opts.signal?.aborted) {throw new DownloaderError('CANCELLED', 'Cancelled');}
|
||||
throw err;
|
||||
}
|
||||
|
||||
_safeProgress(onProgress, { phase: 'verifying', source: 'npm', loaded: bytesDownloaded, total });
|
||||
|
||||
if (!state.found) {
|
||||
await _safeUnlink(tempPath);
|
||||
throw new DownloaderError('INTEGRITY', `Tarball missing expected entry ${platform.tarEntry}`, { platformKey: platform.key });
|
||||
}
|
||||
|
||||
const computed = hash.digest('base64');
|
||||
if (computed !== expectedB64) {
|
||||
await _safeUnlink(tempPath);
|
||||
throw new DownloaderError('INTEGRITY', 'npm tarball hash mismatch', { algo });
|
||||
}
|
||||
|
||||
_safeProgress(onProgress, { phase: 'installing', source: 'npm' });
|
||||
const finalPath = await _finalize(tempPath, path.join(opts.destDir, platform.binaryName));
|
||||
return { binaryPath: finalPath, version, source: 'npm', bytesDownloaded };
|
||||
}
|
||||
|
||||
// ------------- CDN source -------------
|
||||
|
||||
interface CdnManifest {
|
||||
platforms: Record<string, { checksum: string }>;
|
||||
}
|
||||
|
||||
async function _downloadFromCdn(platform: PlatformKey, opts: DownloadOptions): Promise<DownloadResult> {
|
||||
const onProgress = opts.onProgress;
|
||||
const base = opts.cdnBase || DEFAULT_CDN_BASE;
|
||||
_safeProgress(onProgress, { phase: 'resolving', source: 'cdn', message: 'Looking up latest version' });
|
||||
|
||||
const versionRaw = (await _fetchText(`${base}/latest`, opts.signal)).trim();
|
||||
if (!/^\d+\.\d+\.\d+(-[\w.-]+)?$/.test(versionRaw)) {
|
||||
throw new DownloaderError('NETWORK', 'CDN returned invalid version string');
|
||||
}
|
||||
const version = versionRaw;
|
||||
|
||||
const manifest = await _fetchJson<CdnManifest>(`${base}/${version}/manifest.json`, opts.signal);
|
||||
const expectedHex = manifest.platforms?.[platform.key]?.checksum;
|
||||
if (!expectedHex || !/^[a-f0-9]{64}$/i.test(expectedHex)) {
|
||||
throw new DownloaderError('INTEGRITY', `CDN manifest missing checksum for ${platform.key}`, { platformKey: platform.key });
|
||||
}
|
||||
|
||||
const binName = process.platform === 'win32' ? 'claude.exe' : 'claude';
|
||||
const binUrl = `${base}/${version}/${platform.key}/${binName}`;
|
||||
const tempPath = path.join(opts.destDir, `.claude.download.${process.pid}.${Date.now()}`);
|
||||
const writeStream = fs.createWriteStream(tempPath);
|
||||
const hash = crypto.createHash('sha256');
|
||||
|
||||
_safeProgress(onProgress, { phase: 'downloading', source: 'cdn', loaded: 0 });
|
||||
|
||||
let res: http.IncomingMessage;
|
||||
try {
|
||||
res = await _httpGet(binUrl, opts.signal);
|
||||
} catch (err) {
|
||||
writeStream.destroy();
|
||||
await _safeUnlink(tempPath);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const total = Number(res.headers['content-length']) || undefined;
|
||||
let bytesDownloaded = 0;
|
||||
let lastProgressAt = 0;
|
||||
|
||||
const onAbort = () => {
|
||||
res.destroy();
|
||||
writeStream.destroy();
|
||||
};
|
||||
opts.signal?.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
const responseDone = new Promise<void>((resolve, reject) => {
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
bytesDownloaded += chunk.length;
|
||||
hash.update(chunk);
|
||||
const now = Date.now();
|
||||
if (now - lastProgressAt > PROGRESS_THROTTLE_MS) {
|
||||
lastProgressAt = now;
|
||||
_safeProgress(onProgress, { phase: 'downloading', source: 'cdn', loaded: bytesDownloaded, total });
|
||||
}
|
||||
});
|
||||
res.on('end', () => resolve());
|
||||
res.on('error', (err) => reject(new DownloaderError('NETWORK', `Response stream error (${_errCode(err, 'NETERR')})`, { code: _errCode(err, 'NETERR') }, err)));
|
||||
});
|
||||
|
||||
// Wait for 'close' (fd released), not just 'finish' (data flushed). Matters on
|
||||
// Windows — rename() fails with EBUSY if the underlying handle is still open.
|
||||
const writeDone = new Promise<void>((resolve, reject) => {
|
||||
writeStream.on('close', () => resolve());
|
||||
writeStream.on('error', (err) => reject(new DownloaderError('WRITE', `Write failed (${_errCode(err, 'WRITEERR')})`, { code: _errCode(err, 'WRITEERR') }, err)));
|
||||
});
|
||||
|
||||
res.pipe(writeStream);
|
||||
|
||||
try {
|
||||
await responseDone;
|
||||
writeStream.end();
|
||||
await writeDone;
|
||||
} catch (err) {
|
||||
res.destroy();
|
||||
writeStream.destroy();
|
||||
await _safeUnlink(tempPath);
|
||||
if (opts.signal?.aborted) {throw new DownloaderError('CANCELLED', 'Cancelled');}
|
||||
throw err;
|
||||
}
|
||||
|
||||
_safeProgress(onProgress, { phase: 'verifying', source: 'cdn', loaded: bytesDownloaded, total });
|
||||
|
||||
const computedHex = hash.digest('hex');
|
||||
if (computedHex.toLowerCase() !== expectedHex.toLowerCase()) {
|
||||
await _safeUnlink(tempPath);
|
||||
throw new DownloaderError('INTEGRITY', 'CDN binary hash mismatch');
|
||||
}
|
||||
|
||||
_safeProgress(onProgress, { phase: 'installing', source: 'cdn' });
|
||||
const finalPath = await _finalize(tempPath, path.join(opts.destDir, platform.binaryName));
|
||||
return { binaryPath: finalPath, version, source: 'cdn', bytesDownloaded };
|
||||
}
|
||||
|
||||
// ------------- Finalize (chmod + atomic rename) -------------
|
||||
|
||||
async function _finalize(tempPath: string, finalPath: string): Promise<string> {
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
fs.chmodSync(tempPath, 0o755);
|
||||
} catch (err) {
|
||||
await _safeUnlink(tempPath);
|
||||
const code = _errCode(err, 'CHMODERR');
|
||||
throw new DownloaderError('WRITE', `chmod failed (${code})`, { code }, err);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.renameSync(tempPath, finalPath);
|
||||
} catch (err) {
|
||||
const code = _errCode(err, 'RENAMEERR');
|
||||
// EXDEV means temp and final are on different filesystems (shouldn't happen,
|
||||
// but defensive).
|
||||
if (code === 'EXDEV') {
|
||||
try {
|
||||
fs.copyFileSync(tempPath, finalPath);
|
||||
await _safeUnlink(tempPath);
|
||||
return finalPath;
|
||||
} catch (copyErr) {
|
||||
await _safeUnlink(tempPath);
|
||||
const ccode = _errCode(copyErr, 'COPYERR');
|
||||
throw new DownloaderError('WRITE', `Copy to final path failed (${ccode})`, { code: ccode }, copyErr);
|
||||
}
|
||||
}
|
||||
await _safeUnlink(tempPath);
|
||||
// EBUSY/EPERM on Windows means the target is currently running — we can't replace it.
|
||||
if (code === 'EBUSY' || code === 'EPERM') {
|
||||
throw new DownloaderError('WRITE', 'Target binary is in use. Close any running claude sessions and try again.', { code }, err);
|
||||
}
|
||||
throw new DownloaderError('WRITE', `Rename failed (${code})`, { code }, err);
|
||||
}
|
||||
return finalPath;
|
||||
}
|
||||
|
||||
async function _safeUnlink(p: string): Promise<void> {
|
||||
try {
|
||||
await fs.promises.unlink(p);
|
||||
} catch {
|
||||
// ignore — cleanup best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// ------------- Public orchestrator -------------
|
||||
|
||||
export async function downloadClaude(opts: DownloadOptions): Promise<DownloadResult> {
|
||||
const platform = detectPlatform();
|
||||
if (!platform) {
|
||||
throw new DownloaderError('UNSUPPORTED_PLATFORM', `Unsupported platform: ${process.platform}/${os.arch()}`, {
|
||||
platform: process.platform,
|
||||
arch: os.arch(),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(opts.destDir, { recursive: true });
|
||||
} catch (err) {
|
||||
const code = _errCode(err, 'MKDIRERR');
|
||||
// Never include the path — destDir is under the user's home directory and
|
||||
// would leak the username if posted to analytics.
|
||||
throw new DownloaderError('WRITE', `Could not create download directory (${code})`, { code }, err);
|
||||
}
|
||||
|
||||
let npmErr: unknown;
|
||||
try {
|
||||
return await _downloadFromNpm(platform, opts);
|
||||
} catch (err) {
|
||||
if (err instanceof DownloaderError && err.code === 'CANCELLED') {throw err;}
|
||||
npmErr = err;
|
||||
_safeProgress(opts.onProgress, { phase: 'fallback', source: 'cdn', message: 'npm source failed — retrying via CDN' });
|
||||
}
|
||||
|
||||
try {
|
||||
return await _downloadFromCdn(platform, opts);
|
||||
} catch (cdnErr) {
|
||||
if (cdnErr instanceof DownloaderError && cdnErr.code === 'CANCELLED') {throw cdnErr;}
|
||||
const npmCode = npmErr instanceof DownloaderError ? npmErr.code : 'NETWORK';
|
||||
const cdnCode = cdnErr instanceof DownloaderError ? cdnErr.code : 'NETWORK';
|
||||
throw new DownloaderError(
|
||||
'AGGREGATE',
|
||||
`Both sources failed (npm: ${npmCode}, cdn: ${cdnCode}).`,
|
||||
{ npmCode, cdnCode },
|
||||
[npmErr, cdnErr],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------- Internal exports for tests -------------
|
||||
// These are NOT part of the public API — consumers should use downloadClaude
|
||||
// and detectPlatform. They're exported here so the test suite can unit-test the
|
||||
// tar parser, octal parsing, and error-code helpers without network I/O.
|
||||
|
||||
/** @internal */
|
||||
export const __test__ = {
|
||||
parseOctal: _parseOctal,
|
||||
readTarHeader: _readTarHeader,
|
||||
processTarChunk: _processTarChunk,
|
||||
errCode: _errCode,
|
||||
safeProgress: _safeProgress,
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
export type __TarExtractState__ = TarExtractState;
|
||||
1754
src/extension.ts
1754
src/extension.ts
File diff suppressed because it is too large
Load Diff
148
src/model-updater.ts
Normal file
148
src/model-updater.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
interface ApiModel {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
pricing?: { prompt: number; completion: number; currency?: string; unit?: string };
|
||||
context_length?: number;
|
||||
max_output_tokens?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface BundledModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
provider: string;
|
||||
quickLabel?: string;
|
||||
context_length?: number;
|
||||
max_output_tokens?: number;
|
||||
tierModels?: { sonnet: string; opus: string; haiku: string };
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface ProviderResolver {
|
||||
main: RegExp;
|
||||
opus?: RegExp;
|
||||
haiku?: RegExp;
|
||||
}
|
||||
|
||||
function parseVersion(ver: string): number[] {
|
||||
return ver.split('.').map(Number);
|
||||
}
|
||||
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const va = parseVersion(a);
|
||||
const vb = parseVersion(b);
|
||||
for (let i = 0; i < Math.max(va.length, vb.length); i++) {
|
||||
const na = va[i] || 0;
|
||||
const nb = vb[i] || 0;
|
||||
if (na !== nb) { return na - nb; }
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function findHighestMatch(apiModels: ApiModel[], regex: RegExp): ApiModel | null {
|
||||
let best: ApiModel | null = null;
|
||||
let bestVer: string | null = null;
|
||||
for (const m of apiModels) {
|
||||
const match = regex.exec(m.id);
|
||||
if (match) {
|
||||
const ver = match[1] || '0';
|
||||
if (!bestVer || compareVersions(ver, bestVer) > 0) {
|
||||
bestVer = ver;
|
||||
best = m;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
const providerResolvers: Record<string, ProviderResolver> = {
|
||||
'zai/glm-': {
|
||||
main: /^zai\/glm-(\d+(?:\.\d+)?)$/,
|
||||
haiku: /^zai\/GLM-([\d.]+)-(?:Air|Flash)$/i
|
||||
},
|
||||
'openai/gpt-': {
|
||||
main: /^openai\/gpt-([\d.]+)$/,
|
||||
haiku: /^openai\/gpt-([\d.]+)-mini$/
|
||||
},
|
||||
'gemini-': {
|
||||
main: /^(?:google\/)?gemini-([\d.]+)-pro-preview$/,
|
||||
opus: /^(?:google\/)?gemini-([\d.]+)-pro-preview-thinking$/,
|
||||
haiku: /^(?:google\/)?gemini-([\d.]+)-flash(?:-preview)?$/
|
||||
},
|
||||
'deepseek/deepseek-': {
|
||||
main: /^deepseek\/deepseek-v([\d.]+)[-:]thinking$/
|
||||
},
|
||||
'minimax/minimax-': {
|
||||
main: /^minimax\/minimax-m([\d.]+)$/
|
||||
},
|
||||
'moonshotai/kimi-': {
|
||||
main: /^moonshotai\/kimi-k([\d.]+)$/,
|
||||
haiku: /^moonshotai\/kimi-k([\d.]+)-turbo$/
|
||||
}
|
||||
};
|
||||
|
||||
export function resolveLatestModels(apiModels: ApiModel[], bundledModels: BundledModel[]): BundledModel[] {
|
||||
return bundledModels.map(bundled => {
|
||||
const b: BundledModel = JSON.parse(JSON.stringify(bundled));
|
||||
|
||||
let resolver: ProviderResolver | null = null;
|
||||
for (const prefix of Object.keys(providerResolvers)) {
|
||||
if (b.id.toLowerCase().startsWith(prefix)) {
|
||||
resolver = providerResolvers[prefix];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!resolver) { return b; }
|
||||
|
||||
// Resolve main (sonnet-tier) model
|
||||
const mainMatch = findHighestMatch(apiModels, resolver.main);
|
||||
if (mainMatch) {
|
||||
b.id = mainMatch.id;
|
||||
b.name = mainMatch.name || b.name;
|
||||
b.description = mainMatch.description || b.description;
|
||||
b.context_length = mainMatch.context_length || b.context_length;
|
||||
b.max_output_tokens = mainMatch.max_output_tokens || b.max_output_tokens;
|
||||
if (b.tierModels) {
|
||||
b.tierModels.sonnet = mainMatch.id;
|
||||
if (!resolver.opus) {
|
||||
b.tierModels.opus = mainMatch.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve opus-tier model (e.g. Gemini thinking variant)
|
||||
if (resolver.opus && b.tierModels) {
|
||||
const opusMatch = findHighestMatch(apiModels, resolver.opus);
|
||||
if (opusMatch) {
|
||||
b.tierModels.opus = opusMatch.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve haiku-tier model
|
||||
if (resolver.haiku && b.tierModels) {
|
||||
const haikuMatch = findHighestMatch(apiModels, resolver.haiku);
|
||||
if (haikuMatch) {
|
||||
b.tierModels.haiku = haikuMatch.id;
|
||||
}
|
||||
}
|
||||
|
||||
return b;
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAndResolveModels(bundledModels: BundledModel[], apiBaseUrl: string = 'https://ccc.api.opencredits.ai'): Promise<BundledModel[] | null> {
|
||||
try {
|
||||
const response = await fetch(apiBaseUrl + '/v1/models');
|
||||
const data: any = await response.json();
|
||||
const apiModels: ApiModel[] = data.data || data;
|
||||
if (!Array.isArray(apiModels) || apiModels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return resolveLatestModels(apiModels, bundledModels);
|
||||
} catch (e) {
|
||||
console.log('Auto-update models failed:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
153
src/plugins-script.ts
Normal file
153
src/plugins-script.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
const getPluginsScript = () => `
|
||||
// ─── Plugins ───
|
||||
var topPlugins = (window.__topPlugins || []);
|
||||
var pluginsDisplayedList = null;
|
||||
|
||||
function formatPluginName(name) {
|
||||
return name.replace(/-/g, ' ').replace(/\\b\\w/g, function(c) { return c.toUpperCase(); });
|
||||
}
|
||||
|
||||
function showPluginsModal() {
|
||||
sendStats('Plugins modal opened');
|
||||
document.getElementById('pluginsModal').style.display = 'flex';
|
||||
loadInstalledPlugins();
|
||||
renderAvailablePlugins(topPlugins);
|
||||
}
|
||||
|
||||
function hidePluginsModal() {
|
||||
document.getElementById('pluginsModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function loadInstalledPlugins() {
|
||||
vscode.postMessage({ type: 'loadPlugins' });
|
||||
}
|
||||
|
||||
function displayPlugins(data) {
|
||||
var pluginsList = document.getElementById('pluginsList');
|
||||
pluginsList.innerHTML = '';
|
||||
var enabled = data.enabled || {};
|
||||
|
||||
var keys = Object.keys(enabled);
|
||||
if (keys.length === 0) {
|
||||
pluginsList.innerHTML = '<div class="no-servers">' +
|
||||
'<div class="no-servers-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg></div>' +
|
||||
'<div class="no-servers-text">No plugins enabled</div>' +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
keys.forEach(function(installId) {
|
||||
var isEnabled = enabled[installId];
|
||||
var name = installId.replace(/@.*$/, '');
|
||||
var displayName = formatPluginName(name);
|
||||
var plugin = topPlugins.find(function(p) { return p.installId === installId; });
|
||||
var desc = plugin ? plugin.description : '';
|
||||
var verified = plugin ? plugin.verified : false;
|
||||
|
||||
var item = document.createElement('div');
|
||||
item.className = 'mcp-server-item';
|
||||
var verifiedHtml = verified ? '<span class="marketplace-item-verified" title="Anthropic verified">✓</span>' : '';
|
||||
var statusHtml = isEnabled ? '<span class="server-type" style="background:rgba(0,122,204,0.2);color:var(--vscode-charts-blue);">enabled</span>' : '<span class="server-type">disabled</span>';
|
||||
item.innerHTML = '<div class="server-info" style="min-width:0;overflow:hidden;">' +
|
||||
'<div class="server-name">' + escapeHtml(displayName) + verifiedHtml + ' ' + statusHtml + '</div>' +
|
||||
(desc ? '<div class="server-config" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + escapeHtml(desc) + '</div>' : '') +
|
||||
'</div>' +
|
||||
'<div class="server-actions" style="flex-shrink:0;">' +
|
||||
'<button class="btn outlined server-delete-btn" data-plugin="' + escapeHtml(installId) + '" onclick="removePlugin(this.dataset.plugin)">Remove</button>' +
|
||||
'</div>';
|
||||
pluginsList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function renderAvailablePlugins(plugins) {
|
||||
var grid = document.getElementById('pluginsGrid');
|
||||
if (!grid) return;
|
||||
if (!plugins || plugins.length === 0) {
|
||||
grid.innerHTML = '<div class="marketplace-loading">No plugins found.</div>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
plugins.forEach(function(plugin) {
|
||||
var name = plugin.name || 'Unknown';
|
||||
var displayName = formatPluginName(name);
|
||||
var desc = escapeHtml(plugin.description || 'No description');
|
||||
var verified = plugin.verified;
|
||||
var safeId = escapeHtml(plugin.installId || name).replace(/'/g, ''');
|
||||
|
||||
html += '<div class="marketplace-item" data-plugin-id="' + safeId + '" onclick="showPluginDetail(this.dataset.pluginId)">' +
|
||||
'<div class="marketplace-item-header">' +
|
||||
'<div class="marketplace-item-icon-placeholder">' + escapeHtml(displayName.charAt(0).toUpperCase()) + '</div>' +
|
||||
'<div class="marketplace-item-info">' +
|
||||
'<div class="marketplace-item-name">' + escapeHtml(displayName) + '</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="marketplace-item-desc">' + desc + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
grid.innerHTML = html;
|
||||
}
|
||||
|
||||
function searchPlugins(query) {
|
||||
if (!query) {
|
||||
renderAvailablePlugins(topPlugins);
|
||||
return;
|
||||
}
|
||||
var q = query.toLowerCase();
|
||||
var filtered = topPlugins.filter(function(p) {
|
||||
return (p.name && p.name.toLowerCase().indexOf(q) >= 0) ||
|
||||
(p.description && p.description.toLowerCase().indexOf(q) >= 0);
|
||||
});
|
||||
renderAvailablePlugins(filtered);
|
||||
}
|
||||
|
||||
function showPluginDetail(installId) {
|
||||
var plugin = topPlugins.find(function(p) { return p.installId === installId; });
|
||||
if (!plugin) return;
|
||||
|
||||
var name = plugin.name || 'Unknown';
|
||||
var displayName = formatPluginName(name);
|
||||
var desc = plugin.description || 'No description available.';
|
||||
var verified = plugin.verified;
|
||||
var verifiedHtml = verified ? '<span class="marketplace-item-verified" title="Anthropic verified">✓ Anthropic verified</span>' : '';
|
||||
|
||||
var grid = document.getElementById('pluginsGrid');
|
||||
pluginsDisplayedList = grid.innerHTML;
|
||||
|
||||
grid.innerHTML = '<div class="marketplace-detail">' +
|
||||
'<button class="marketplace-back-btn" onclick="backToPluginsList()">← Back</button>' +
|
||||
'<div class="marketplace-detail-header">' +
|
||||
'<div class="marketplace-item-icon-placeholder" style="width:40px;height:40px;font-size:18px;">' + escapeHtml(displayName.charAt(0).toUpperCase()) + '</div>' +
|
||||
'<div class="marketplace-detail-header-info">' +
|
||||
'<div class="marketplace-detail-name">' + escapeHtml(displayName) + '</div>' +
|
||||
'<div class="marketplace-detail-header-meta">' + verifiedHtml + '</div>' +
|
||||
'</div>' +
|
||||
'<button class="btn marketplace-install-btn" data-plugin="' + escapeHtml(installId) + '" onclick="installPlugin(this.dataset.plugin)">Enable</button>' +
|
||||
'</div>' +
|
||||
'<div class="marketplace-detail-desc">' + escapeHtml(desc) + '</div>' +
|
||||
'<div class="marketplace-detail-row"><a href="https://github.com/anthropics/claude-plugins-official/tree/main/' + (plugin.type === 'official' ? 'plugins' : 'external_plugins') + '/' + escapeHtml(name) + '" target="_blank" class="marketplace-detail-link">View on GitHub</a></div>' +
|
||||
'<div style="font-size:11px;color:var(--vscode-descriptionForeground);margin-top:4px;">Adds <code style="font-size:10px;">' + escapeHtml(installId) + '</code> to .claude/settings.json</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function backToPluginsList() {
|
||||
var grid = document.getElementById('pluginsGrid');
|
||||
if (pluginsDisplayedList) {
|
||||
grid.innerHTML = pluginsDisplayedList;
|
||||
} else {
|
||||
renderAvailablePlugins(topPlugins);
|
||||
}
|
||||
}
|
||||
|
||||
function installPlugin(installId) {
|
||||
sendStats('Plugin installed', { plugin: installId });
|
||||
vscode.postMessage({ type: 'installPlugin', installId: installId });
|
||||
hidePluginsModal();
|
||||
}
|
||||
|
||||
function removePlugin(installId) {
|
||||
sendStats('Plugin removed', { plugin: installId });
|
||||
vscode.postMessage({ type: 'removePlugin', installId: installId });
|
||||
}
|
||||
`;
|
||||
|
||||
export default getPluginsScript;
|
||||
26
src/plugins-ui.ts
Normal file
26
src/plugins-ui.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
const getPluginsHtml = () => `
|
||||
<!-- Plugins modal -->
|
||||
<div id="pluginsModal" class="tools-modal" style="display: none;">
|
||||
<div class="tools-modal-content">
|
||||
<div class="tools-modal-header">
|
||||
<span>Plugins</span>
|
||||
<button class="tools-close-btn" onclick="hidePluginsModal()">✕</button>
|
||||
</div>
|
||||
<div class="tools-list">
|
||||
<div class="mcp-servers-list" id="pluginsList">
|
||||
<!-- Installed plugins will be loaded here -->
|
||||
</div>
|
||||
<div class="mcp-popular-servers" id="pluginsMarketplace">
|
||||
<h4>Available Plugins</h4>
|
||||
<div class="marketplace-search">
|
||||
<input type="text" id="pluginsSearch" placeholder="Search plugins..." oninput="searchPlugins(this.value)" />
|
||||
</div>
|
||||
<div class="marketplace-grid" id="pluginsGrid">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export default getPluginsHtml;
|
||||
66
src/recommended-models.json
Normal file
66
src/recommended-models.json
Normal file
@@ -0,0 +1,66 @@
|
||||
[
|
||||
{
|
||||
"id": "openai/gpt-5.5",
|
||||
"name": "GPT 5.5",
|
||||
"description": "OpenAI's GPT-5.5 with strong reasoning and agentic tool use.",
|
||||
"context_length": 400000,
|
||||
"max_output_tokens": 128000,
|
||||
"credits_per_request": 4.921875,
|
||||
"provider": "OpenAI",
|
||||
"quickLabel": "GPT",
|
||||
"tierModels": { "sonnet": "openai/gpt-5.5", "opus": "openai/gpt-5.5", "haiku": "openai/gpt-5.4-mini" }
|
||||
},
|
||||
{
|
||||
"id": "google/gemini-3.1-pro-preview",
|
||||
"name": "Gemini 3.1 Pro Preview",
|
||||
"description": "Google's Gemini 3.1 Pro with enhanced reasoning and multimodal support.",
|
||||
"context_length": 1000000,
|
||||
"max_output_tokens": 64000,
|
||||
"credits_per_request": 4.375,
|
||||
"provider": "Google",
|
||||
"quickLabel": "Gemini",
|
||||
"tierModels": { "sonnet": "google/gemini-3.1-pro-preview", "opus": "google/gemini-3.1-pro-preview", "haiku": "google/gemini-3-flash" }
|
||||
},
|
||||
{
|
||||
"id": "minimax/minimax-m2.7",
|
||||
"name": "Minimax M2.7",
|
||||
"description": "MiniMax M2.7 with enhanced context understanding and improved complex tool use. Optimized for agentic workflows and long-horizon tasks.",
|
||||
"context_length": 204800,
|
||||
"max_output_tokens": 131000,
|
||||
"credits_per_request": 0.46875,
|
||||
"provider": "MiniMax",
|
||||
"quickLabel": "MiniMax"
|
||||
},
|
||||
{
|
||||
"id": "moonshotai/kimi-k2.5",
|
||||
"name": "Kimi K2.5",
|
||||
"description": "Kimi K2.5 is Moonshot AI's native multimodal model with strong general reasoning, visual coding, and agentic tool-calling.",
|
||||
"context_length": 262114,
|
||||
"max_output_tokens": 262114,
|
||||
"credits_per_request": 1.125,
|
||||
"provider": "Moonshot AI",
|
||||
"quickLabel": "Kimi",
|
||||
"tierModels": { "sonnet": "moonshotai/kimi-k2.5", "opus": "moonshotai/kimi-k2.5", "haiku": "moonshotai/kimi-k2-turbo" }
|
||||
},
|
||||
{
|
||||
"id": "zai/glm-5",
|
||||
"name": "GLM 5",
|
||||
"description": "GLM-5 is the latest GLM series text model with stronger reasoning, long-context chat, and reliable tool use.",
|
||||
"context_length": 202800,
|
||||
"max_output_tokens": 131100,
|
||||
"credits_per_request": 1.3125,
|
||||
"provider": "Zhipu AI",
|
||||
"quickLabel": "GLM",
|
||||
"tierModels": { "sonnet": "zai/glm-5", "opus": "zai/glm-5", "haiku": "zai/glm-4.7-flash" }
|
||||
},
|
||||
{
|
||||
"id": "deepseek/deepseek-v3.2-thinking",
|
||||
"name": "DeepSeek V3.2 Thinking",
|
||||
"description": "DeepSeek V3.2 thinking/reasoner mode. Reasoning-first model built for agents. First DeepSeek model with thinking-in-tool-use capability.",
|
||||
"context_length": 128000,
|
||||
"max_output_tokens": 64000,
|
||||
"credits_per_request": 0.21875,
|
||||
"provider": "DeepSeek",
|
||||
"quickLabel": "DeepSeek"
|
||||
}
|
||||
]
|
||||
265
src/router/formatRequest.ts
Normal file
265
src/router/formatRequest.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
interface MessageCreateParamsBase {
|
||||
model: string;
|
||||
messages: any[];
|
||||
system?: any;
|
||||
temperature?: number;
|
||||
tools?: any[];
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validates OpenAI format messages to ensure complete tool_calls/tool message pairing.
|
||||
* Requires tool messages to immediately follow assistant messages with tool_calls.
|
||||
* Enforces strict immediate following sequence between tool_calls and tool messages.
|
||||
*/
|
||||
function validateOpenAIToolCalls(messages: any[]): any[] {
|
||||
const validatedMessages: any[] = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const currentMessage = { ...messages[i] };
|
||||
|
||||
// Process assistant messages with tool_calls
|
||||
if (currentMessage.role === "assistant" && currentMessage.tool_calls) {
|
||||
const validToolCalls: any[] = [];
|
||||
const removedToolCallIds: string[] = [];
|
||||
|
||||
// Collect all immediately following tool messages
|
||||
const immediateToolMessages: any[] = [];
|
||||
let j = i + 1;
|
||||
while (j < messages.length && messages[j].role === "tool") {
|
||||
immediateToolMessages.push(messages[j]);
|
||||
j++;
|
||||
}
|
||||
|
||||
// For each tool_call, check if there's an immediately following tool message
|
||||
currentMessage.tool_calls.forEach((toolCall: any) => {
|
||||
const hasImmediateToolMessage = immediateToolMessages.some(toolMsg =>
|
||||
toolMsg.tool_call_id === toolCall.id
|
||||
);
|
||||
|
||||
if (hasImmediateToolMessage) {
|
||||
validToolCalls.push(toolCall);
|
||||
} else {
|
||||
removedToolCallIds.push(toolCall.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Update the assistant message
|
||||
if (validToolCalls.length > 0) {
|
||||
currentMessage.tool_calls = validToolCalls;
|
||||
} else {
|
||||
delete currentMessage.tool_calls;
|
||||
}
|
||||
|
||||
|
||||
// Only include message if it has content or valid tool_calls
|
||||
if (currentMessage.content || currentMessage.tool_calls) {
|
||||
validatedMessages.push(currentMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Process tool messages
|
||||
else if (currentMessage.role === "tool") {
|
||||
let hasImmediateToolCall = false;
|
||||
|
||||
// Check if the immediately preceding assistant message has matching tool_call
|
||||
if (i > 0) {
|
||||
const prevMessage = messages[i - 1];
|
||||
if (prevMessage.role === "assistant" && prevMessage.tool_calls) {
|
||||
hasImmediateToolCall = prevMessage.tool_calls.some((toolCall: any) =>
|
||||
toolCall.id === currentMessage.tool_call_id
|
||||
);
|
||||
} else if (prevMessage.role === "tool") {
|
||||
// Check for assistant message before the sequence of tool messages
|
||||
for (let k = i - 1; k >= 0; k--) {
|
||||
if (messages[k].role === "tool") continue;
|
||||
if (messages[k].role === "assistant" && messages[k].tool_calls) {
|
||||
hasImmediateToolCall = messages[k].tool_calls.some((toolCall: any) =>
|
||||
toolCall.id === currentMessage.tool_call_id
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasImmediateToolCall) {
|
||||
validatedMessages.push(currentMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// For all other message types, include as-is
|
||||
else {
|
||||
validatedMessages.push(currentMessage);
|
||||
}
|
||||
}
|
||||
|
||||
return validatedMessages;
|
||||
}
|
||||
|
||||
// Model configuration - set from extension
|
||||
interface ModelConfig {
|
||||
haikuModel: string;
|
||||
sonnetModel: string;
|
||||
opusModel: string;
|
||||
}
|
||||
|
||||
let modelConfig: ModelConfig | null = null;
|
||||
|
||||
export function setModelConfig(config: ModelConfig): void {
|
||||
modelConfig = config;
|
||||
console.log('[Router] Model config updated:', config);
|
||||
}
|
||||
|
||||
export function mapModel(anthropicModel: string): string {
|
||||
console.log('[Router] Mapping model:', anthropicModel);
|
||||
|
||||
// If model already contains '/', it's already a provider model ID - return as-is
|
||||
if (anthropicModel.includes('/')) {
|
||||
console.log(`[Router] Model already has provider prefix, passing through: ${anthropicModel}`);
|
||||
return anthropicModel;
|
||||
}
|
||||
|
||||
if (!modelConfig) {
|
||||
console.log('[Router] No model config set, returning as-is');
|
||||
return anthropicModel;
|
||||
}
|
||||
|
||||
if (anthropicModel.includes('haiku') && modelConfig.haikuModel) {
|
||||
console.log(`[Router] Mapping haiku -> ${modelConfig.haikuModel}`);
|
||||
return modelConfig.haikuModel;
|
||||
} else if (anthropicModel.includes('sonnet') && modelConfig.sonnetModel) {
|
||||
console.log(`[Router] Mapping sonnet -> ${modelConfig.sonnetModel}`);
|
||||
return modelConfig.sonnetModel;
|
||||
} else if (anthropicModel.includes('opus') && modelConfig.opusModel) {
|
||||
console.log(`[Router] Mapping opus -> ${modelConfig.opusModel}`);
|
||||
return modelConfig.opusModel;
|
||||
}
|
||||
|
||||
console.log(`[Router] No mapping found for model: ${anthropicModel}, passing through`);
|
||||
return anthropicModel;
|
||||
}
|
||||
|
||||
export function formatAnthropicToOpenAI(body: MessageCreateParamsBase): any {
|
||||
const { model, messages, system = [], temperature, tools, stream } = body;
|
||||
|
||||
const openAIMessages = Array.isArray(messages)
|
||||
? messages.flatMap((anthropicMessage) => {
|
||||
const openAiMessagesFromThisAnthropicMessage: any[] = [];
|
||||
|
||||
if (!Array.isArray(anthropicMessage.content)) {
|
||||
if (typeof anthropicMessage.content === "string") {
|
||||
openAiMessagesFromThisAnthropicMessage.push({
|
||||
role: anthropicMessage.role,
|
||||
content: anthropicMessage.content,
|
||||
});
|
||||
}
|
||||
return openAiMessagesFromThisAnthropicMessage;
|
||||
}
|
||||
|
||||
if (anthropicMessage.role === "assistant") {
|
||||
const assistantMessage: any = {
|
||||
role: "assistant",
|
||||
content: null,
|
||||
};
|
||||
let textContent = "";
|
||||
const toolCalls: any[] = [];
|
||||
|
||||
anthropicMessage.content.forEach((contentPart: any) => {
|
||||
if (contentPart.type === "text") {
|
||||
textContent += (typeof contentPart.text === "string"
|
||||
? contentPart.text
|
||||
: JSON.stringify(contentPart.text)) + "\n";
|
||||
} else if (contentPart.type === "tool_use") {
|
||||
toolCalls.push({
|
||||
id: contentPart.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: contentPart.name,
|
||||
arguments: JSON.stringify(contentPart.input),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const trimmedTextContent = textContent.trim();
|
||||
if (trimmedTextContent.length > 0) {
|
||||
assistantMessage.content = trimmedTextContent;
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
assistantMessage.tool_calls = toolCalls;
|
||||
}
|
||||
if (assistantMessage.content || (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0)) {
|
||||
openAiMessagesFromThisAnthropicMessage.push(assistantMessage);
|
||||
}
|
||||
} else if (anthropicMessage.role === "user") {
|
||||
let userTextMessageContent = "";
|
||||
const subsequentToolMessages: any[] = [];
|
||||
|
||||
anthropicMessage.content.forEach((contentPart: any) => {
|
||||
if (contentPart.type === "text") {
|
||||
userTextMessageContent += (typeof contentPart.text === "string"
|
||||
? contentPart.text
|
||||
: JSON.stringify(contentPart.text)) + "\n";
|
||||
} else if (contentPart.type === "tool_result") {
|
||||
subsequentToolMessages.push({
|
||||
role: "tool",
|
||||
tool_call_id: contentPart.tool_use_id,
|
||||
content: typeof contentPart.content === "string"
|
||||
? contentPart.content
|
||||
: JSON.stringify(contentPart.content),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const trimmedUserText = userTextMessageContent.trim();
|
||||
if (trimmedUserText.length > 0) {
|
||||
openAiMessagesFromThisAnthropicMessage.push({
|
||||
role: "user",
|
||||
content: trimmedUserText,
|
||||
});
|
||||
}
|
||||
openAiMessagesFromThisAnthropicMessage.push(...subsequentToolMessages);
|
||||
}
|
||||
return openAiMessagesFromThisAnthropicMessage;
|
||||
})
|
||||
: [];
|
||||
|
||||
const systemMessages = Array.isArray(system)
|
||||
? system.map((item) => ({
|
||||
role: "system",
|
||||
content: typeof item === "string" ? item : item.text
|
||||
}))
|
||||
: typeof system === "string" && system.length > 0
|
||||
? [{ role: "system", content: system }]
|
||||
: [];
|
||||
|
||||
const data: any = {
|
||||
model: mapModel(model),
|
||||
messages: [...systemMessages, ...openAIMessages],
|
||||
temperature,
|
||||
stream,
|
||||
};
|
||||
|
||||
// Request usage stats in streaming responses
|
||||
if (stream) {
|
||||
data.stream_options = { include_usage: true };
|
||||
}
|
||||
|
||||
if (tools) {
|
||||
data.tools = tools.map((item: any) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
parameters: item.input_schema,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// Validate OpenAI messages to ensure complete tool_calls/tool message pairing
|
||||
data.messages = [...systemMessages, ...validateOpenAIToolCalls(openAIMessages)];
|
||||
|
||||
return data;
|
||||
}
|
||||
37
src/router/formatResponse.ts
Normal file
37
src/router/formatResponse.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export function formatOpenAIToAnthropic(completion: any, model: string): any {
|
||||
const messageId = "msg_" + Date.now();
|
||||
const message = completion.choices[0].message;
|
||||
|
||||
const content: any[] = [];
|
||||
if (message.content) {
|
||||
content.push({ text: message.content, type: "text" });
|
||||
}
|
||||
if (message.tool_calls) {
|
||||
for (const item of message.tool_calls) {
|
||||
content.push({
|
||||
type: 'tool_use',
|
||||
id: item.id,
|
||||
name: item.function?.name,
|
||||
input: item.function?.arguments ? JSON.parse(item.function.arguments) : {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hasToolUse = message.tool_calls && message.tool_calls.length > 0;
|
||||
const usage = completion.usage || {};
|
||||
|
||||
const result = {
|
||||
id: messageId,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: content,
|
||||
stop_reason: hasToolUse ? "tool_use" : "end_turn",
|
||||
stop_sequence: null,
|
||||
model,
|
||||
usage: {
|
||||
input_tokens: usage.prompt_tokens || 0,
|
||||
output_tokens: usage.completion_tokens || 0,
|
||||
},
|
||||
};
|
||||
return result;
|
||||
}
|
||||
2
src/router/index.ts
Normal file
2
src/router/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { startRouter, stopRouter, isRouterRunning, getRouterPort, setBaseUrl } from './server';
|
||||
export { setModelConfig } from './formatRequest';
|
||||
220
src/router/server.ts
Normal file
220
src/router/server.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import * as http from 'http';
|
||||
import { formatAnthropicToOpenAI } from './formatRequest';
|
||||
import { streamOpenAIToAnthropic } from './streamResponse';
|
||||
import { formatOpenAIToAnthropic } from './formatResponse';
|
||||
|
||||
const DEFAULT_PORT = 31548;
|
||||
const DEFAULT_BASE_URL = "http://localhost:8787/v1";
|
||||
|
||||
let server: http.Server | null = null;
|
||||
let currentPort: number = DEFAULT_PORT;
|
||||
let baseUrl: string = DEFAULT_BASE_URL;
|
||||
|
||||
export function setBaseUrl(url: string): void {
|
||||
baseUrl = url || DEFAULT_BASE_URL;
|
||||
console.log('[Router] Base URL set to:', baseUrl);
|
||||
}
|
||||
|
||||
// Helper to parse JSON body
|
||||
async function parseBody(req: http.IncomingMessage): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
req.on('data', chunk => {
|
||||
body += chunk.toString();
|
||||
// Prevent payload too large (50MB limit)
|
||||
if (body.length > 50 * 1024 * 1024) {
|
||||
req.destroy();
|
||||
reject(new Error('Payload too large'));
|
||||
}
|
||||
});
|
||||
req.on('end', () => {
|
||||
try {
|
||||
resolve(body ? JSON.parse(body) : {});
|
||||
} catch (e) {
|
||||
reject(new Error('Invalid JSON'));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function createServer(): http.Server {
|
||||
return http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
||||
const method = req.method || 'GET';
|
||||
|
||||
try {
|
||||
// POST /v1/messages
|
||||
if (url.pathname === '/v1/messages' && method === 'POST') {
|
||||
console.log('[Router] 📥 Received request to /v1/messages');
|
||||
|
||||
const anthropicRequest = await parseBody(req);
|
||||
const openaiRequest = formatAnthropicToOpenAI(anthropicRequest);
|
||||
|
||||
console.log('[Router] 🔄 Converted to OpenAI format:', {
|
||||
model: openaiRequest.model,
|
||||
stream: openaiRequest.stream,
|
||||
messageCount: openaiRequest.messages?.length
|
||||
});
|
||||
|
||||
const bearerToken = (req.headers['x-api-key'] as string) ||
|
||||
(req.headers.authorization as string)?.replace("Bearer ", "").replace("bearer ", "");
|
||||
|
||||
if (!bearerToken || bearerToken.trim() === '') {
|
||||
console.log('[Router] ❌ No bearer token found');
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'authentication_error',
|
||||
message: 'No API key provided. Please configure your OpenCredits user key in environment variables.'
|
||||
}
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${bearerToken}`,
|
||||
"HTTP-Referer": "https://claude-code-chat.local",
|
||||
"X-Title": "Claude-Code-Chat-Router"
|
||||
};
|
||||
|
||||
const openaiResponse = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: fetchHeaders,
|
||||
body: JSON.stringify(openaiRequest),
|
||||
});
|
||||
|
||||
console.log('[Router] 📥 Response status:', openaiResponse.status);
|
||||
|
||||
if (!openaiResponse.ok) {
|
||||
const errorText = await openaiResponse.text();
|
||||
console.log('[Router] ❌ Error:', errorText);
|
||||
|
||||
// Try to parse as JSON, otherwise use raw text
|
||||
let errorMessage = errorText;
|
||||
try {
|
||||
const parsed = JSON.parse(errorText);
|
||||
errorMessage = parsed.error?.message || parsed.message || errorText;
|
||||
} catch {}
|
||||
|
||||
res.writeHead(openaiResponse.status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: openaiResponse.status === 401 ? 'authentication_error' : 'api_error',
|
||||
message: `[Router] ${errorMessage}`
|
||||
}
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (openaiRequest.stream) {
|
||||
console.log('[Router] 🌊 Starting stream response');
|
||||
const anthropicStream = streamOpenAIToAnthropic(
|
||||
openaiResponse.body as ReadableStream,
|
||||
openaiRequest.model
|
||||
);
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
});
|
||||
|
||||
const reader = anthropicStream.getReader();
|
||||
|
||||
const pump = async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
res.end();
|
||||
break;
|
||||
}
|
||||
res.write(value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Router] Stream error:', error);
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
|
||||
pump();
|
||||
} else {
|
||||
const openaiData = await openaiResponse.json();
|
||||
const anthropicResponse = formatOpenAIToAnthropic(openaiData, openaiRequest.model);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(anthropicResponse));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 404 Not Found
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('Not Found');
|
||||
} catch (error) {
|
||||
console.error('[Router] Error processing request:', error);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'api_error',
|
||||
message: `[Router] Internal error: ${(error as Error).message}`
|
||||
}
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function startRouter(port: number = DEFAULT_PORT): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (server) {
|
||||
console.log('[Router] Already running on port', currentPort);
|
||||
resolve(currentPort);
|
||||
return;
|
||||
}
|
||||
|
||||
server = createServer();
|
||||
|
||||
server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.log(`[Router] Port ${port} in use, trying ${port + 1}`);
|
||||
server = null;
|
||||
startRouter(port + 1).then(resolve).catch(reject);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
currentPort = port;
|
||||
console.log(`[Router] 🚀 Running on http://localhost:${port}`);
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function stopRouter(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!server) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
server.close(() => {
|
||||
console.log('[Router] Stopped');
|
||||
server = null;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function isRouterRunning(): boolean {
|
||||
return server !== null;
|
||||
}
|
||||
|
||||
export function getRouterPort(): number {
|
||||
return currentPort;
|
||||
}
|
||||
219
src/router/streamResponse.ts
Normal file
219
src/router/streamResponse.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
export function streamOpenAIToAnthropic(openaiStream: ReadableStream, model: string): ReadableStream {
|
||||
const messageId = "msg_" + Date.now();
|
||||
|
||||
const enqueueSSE = (controller: ReadableStreamDefaultController, eventType: string, data: any) => {
|
||||
const sseMessage = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(sseMessage));
|
||||
};
|
||||
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
// Send message_start event
|
||||
const messageStart = {
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: messageId,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [],
|
||||
model,
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
},
|
||||
};
|
||||
enqueueSSE(controller, "message_start", messageStart);
|
||||
|
||||
let contentBlockIndex = 0;
|
||||
let hasAnyBlock = false;
|
||||
let hasStartedTextBlock = false;
|
||||
let isToolUse = false;
|
||||
let currentToolCallId: string | null = null;
|
||||
let toolCallJsonMap = new Map<string, string>();
|
||||
let streamUsage: { input_tokens: number; output_tokens: number } | null = null;
|
||||
|
||||
const reader = openaiStream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
// Process any remaining data in buffer
|
||||
if (buffer.trim()) {
|
||||
const lines = buffer.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.trim() && line.startsWith('data: ')) {
|
||||
const data = line.slice(6).trim();
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
processStreamChunk(parsed);
|
||||
} catch (e) {
|
||||
// Parse error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Decode chunk and add to buffer
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
buffer += chunk;
|
||||
|
||||
// Process complete lines from buffer
|
||||
const lines = buffer.split('\n');
|
||||
// Keep the last potentially incomplete line in buffer
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
// Process complete lines in order
|
||||
for (const line of lines) {
|
||||
if (line.trim() && line.startsWith('data: ')) {
|
||||
const data = line.slice(6).trim();
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
processStreamChunk(parsed);
|
||||
} catch (e) {
|
||||
// Parse error
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
function processStreamChunk(parsed: any) {
|
||||
// Capture usage from the chunk if available
|
||||
if (parsed.usage) {
|
||||
streamUsage = {
|
||||
input_tokens: parsed.usage.prompt_tokens || 0,
|
||||
output_tokens: parsed.usage.completion_tokens || 0,
|
||||
};
|
||||
}
|
||||
|
||||
const delta = parsed.choices?.[0]?.delta;
|
||||
if (delta) {
|
||||
processStreamDelta(delta);
|
||||
}
|
||||
}
|
||||
|
||||
function closeCurrentBlock() {
|
||||
if (hasAnyBlock) {
|
||||
enqueueSSE(controller, "content_block_stop", {
|
||||
type: "content_block_stop",
|
||||
index: contentBlockIndex,
|
||||
});
|
||||
contentBlockIndex++;
|
||||
}
|
||||
hasAnyBlock = true;
|
||||
}
|
||||
|
||||
function processStreamDelta(delta: any) {
|
||||
|
||||
// Handle tool calls
|
||||
if (delta.tool_calls?.length > 0) {
|
||||
for (const toolCall of delta.tool_calls) {
|
||||
const toolCallId = toolCall.id;
|
||||
|
||||
if (toolCallId && toolCallId !== currentToolCallId) {
|
||||
closeCurrentBlock();
|
||||
|
||||
isToolUse = true;
|
||||
hasStartedTextBlock = false;
|
||||
currentToolCallId = toolCallId;
|
||||
toolCallJsonMap.set(toolCallId, "");
|
||||
|
||||
const toolBlock = {
|
||||
type: "tool_use",
|
||||
id: toolCallId,
|
||||
name: toolCall.function?.name,
|
||||
input: {},
|
||||
};
|
||||
|
||||
enqueueSSE(controller, "content_block_start", {
|
||||
type: "content_block_start",
|
||||
index: contentBlockIndex,
|
||||
content_block: toolBlock,
|
||||
});
|
||||
}
|
||||
|
||||
if (toolCall.function?.arguments && currentToolCallId) {
|
||||
const currentJson = toolCallJsonMap.get(currentToolCallId) || "";
|
||||
toolCallJsonMap.set(currentToolCallId, currentJson + toolCall.function.arguments);
|
||||
|
||||
enqueueSSE(controller, "content_block_delta", {
|
||||
type: "content_block_delta",
|
||||
index: contentBlockIndex,
|
||||
delta: {
|
||||
type: "input_json_delta",
|
||||
partial_json: toolCall.function.arguments,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (delta.content) {
|
||||
if (isToolUse) {
|
||||
closeCurrentBlock();
|
||||
isToolUse = false;
|
||||
currentToolCallId = null;
|
||||
}
|
||||
|
||||
if (!hasStartedTextBlock) {
|
||||
if (!hasAnyBlock) {
|
||||
hasAnyBlock = true;
|
||||
}
|
||||
enqueueSSE(controller, "content_block_start", {
|
||||
type: "content_block_start",
|
||||
index: contentBlockIndex,
|
||||
content_block: {
|
||||
type: "text",
|
||||
text: "",
|
||||
},
|
||||
});
|
||||
hasStartedTextBlock = true;
|
||||
}
|
||||
|
||||
enqueueSSE(controller, "content_block_delta", {
|
||||
type: "content_block_delta",
|
||||
index: contentBlockIndex,
|
||||
delta: {
|
||||
type: "text_delta",
|
||||
text: delta.content,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Close last content block
|
||||
if (hasAnyBlock) {
|
||||
enqueueSSE(controller, "content_block_stop", {
|
||||
type: "content_block_stop",
|
||||
index: contentBlockIndex,
|
||||
});
|
||||
}
|
||||
|
||||
// Send message_delta and message_stop
|
||||
enqueueSSE(controller, "message_delta", {
|
||||
type: "message_delta",
|
||||
delta: {
|
||||
stop_reason: isToolUse ? "tool_use" : "end_turn",
|
||||
stop_sequence: null,
|
||||
},
|
||||
usage: streamUsage || { input_tokens: 0, output_tokens: 0 },
|
||||
});
|
||||
|
||||
enqueueSSE(controller, "message_stop", {
|
||||
type: "message_stop",
|
||||
});
|
||||
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
2383
src/script.ts
2383
src/script.ts
File diff suppressed because it is too large
Load Diff
288
src/skills-script.ts
Normal file
288
src/skills-script.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
const getSkillsScript = () => `
|
||||
// ─── Skills ───
|
||||
var skillsSearchTimeout = null;
|
||||
var skillsCache = null;
|
||||
var topSkills = (window.__topSkills || []);
|
||||
|
||||
function showSkillsModal() {
|
||||
sendStats('Skills modal opened');
|
||||
document.getElementById('skillsModal').style.display = 'flex';
|
||||
loadInstalledSkills();
|
||||
if (topSkills.length > 0) {
|
||||
renderFeaturedSkills(topSkills);
|
||||
}
|
||||
}
|
||||
|
||||
function renderFeaturedSkills(skills) {
|
||||
var grid = document.getElementById('skillsGrid');
|
||||
if (!grid) return;
|
||||
var html = '';
|
||||
skills.forEach(function(skill) {
|
||||
var name = skill.name || 'Unknown';
|
||||
var installs = skill.installs || 0;
|
||||
var source = skill.source || '';
|
||||
var installsHtml = installs > 0 ? '<span class="marketplace-item-stars">' + (installs >= 1000 ? (Math.round(installs / 100) / 10) + 'k' : installs) + ' installs</span>' : '';
|
||||
var safeId = escapeHtml(skill.id || name).replace(/'/g, ''');
|
||||
|
||||
var rawUrl = skill.rawUrl || '';
|
||||
var installsText = installs >= 1000 ? (Math.round(installs / 100) / 10) + 'k installs' : (installs > 0 ? installs + ' installs' : '');
|
||||
html += '<div class="marketplace-item" data-skill-id="' + safeId + '" data-skill-source="' + escapeHtml(source) + '" data-skill-name="' + escapeHtml(name) + '" data-skill-rawurl="' + escapeHtml(rawUrl) + '" data-skill-installs="' + escapeHtml(installsText) + '" onclick="installSkillFromMarketplace(this)">' +
|
||||
'<div class="marketplace-item-header">' +
|
||||
'<div class="marketplace-item-icon-placeholder">' + escapeHtml(name.charAt(0).toUpperCase()) + '</div>' +
|
||||
'<div class="marketplace-item-info">' +
|
||||
'<div class="marketplace-item-name">' + escapeHtml(name) + '</div>' +
|
||||
'<div class="marketplace-item-meta">' + installsHtml + '</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="marketplace-item-desc">' + escapeHtml(source) + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
grid.innerHTML = html;
|
||||
}
|
||||
|
||||
function hideSkillsModal() {
|
||||
document.getElementById('skillsModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function loadInstalledSkills() {
|
||||
vscode.postMessage({ type: 'loadSkills' });
|
||||
}
|
||||
|
||||
function displaySkills(skills) {
|
||||
var skillsList = document.getElementById('skillsList');
|
||||
skillsList.innerHTML = '';
|
||||
|
||||
if (!skills || skills.length === 0) {
|
||||
skillsList.innerHTML = '<div class="no-servers">' +
|
||||
'<div class="no-servers-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div>' +
|
||||
'<div class="no-servers-text">No skills installed</div>' +
|
||||
'<button class="btn outlined no-servers-btn" onclick="showSkillAddForm()">+ Create skill</button>' +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
skills.forEach(function(skill, idx) {
|
||||
var item = document.createElement('div');
|
||||
item.className = 'mcp-server-item';
|
||||
item.style.flexDirection = 'column';
|
||||
item.style.alignItems = 'stretch';
|
||||
var desc = skill.description || 'No description';
|
||||
var content = skill.content || '';
|
||||
var detailId = 'skill-detail-' + idx;
|
||||
item.innerHTML = '<div class="skill-item-row">' +
|
||||
'<div class="skill-item-info">' +
|
||||
'<div class="server-name">' + escapeHtml(skill.name) + ' <span class="server-type">' + escapeHtml(skill.scope) + '</span></div>' +
|
||||
'<div class="skill-item-desc">' + escapeHtml(desc) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="server-actions" style="flex-shrink:0;">' +
|
||||
'<button class="btn outlined" style="font-size:11px;padding:3px 8px;" onclick="toggleSkillDetail(\\'' + detailId + '\\')">Details</button>' +
|
||||
'<button class="btn outlined server-delete-btn" data-skill="' + escapeHtml(skill.name) + '" data-scope="' + escapeHtml(skill.scope) + '" onclick="deleteSkill(this.dataset.skill, this.dataset.scope)">Delete</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="' + detailId + '" class="skill-detail-content" style="display:none;">' +
|
||||
'<pre style="white-space:pre-wrap;font-size:11px;color:var(--vscode-descriptionForeground);margin:8px 0 0;max-height:200px;overflow-y:auto;">' + escapeHtml(content) + '</pre>' +
|
||||
'</div>';
|
||||
skillsList.appendChild(item);
|
||||
});
|
||||
|
||||
// Add create button at bottom
|
||||
var addDiv = document.createElement('div');
|
||||
addDiv.className = 'mcp-add-server';
|
||||
addDiv.innerHTML = '<button class="btn outlined" onclick="showSkillAddForm()">+ Create skill</button>';
|
||||
skillsList.appendChild(addDiv);
|
||||
}
|
||||
|
||||
function showSkillAddForm() {
|
||||
document.getElementById('skillsList').style.display = 'none';
|
||||
document.getElementById('skillsMarketplace').style.display = 'none';
|
||||
document.getElementById('skillAddForm').style.display = 'block';
|
||||
// Clear form
|
||||
document.getElementById('skillName').value = '';
|
||||
document.getElementById('skillDescription').value = '';
|
||||
document.getElementById('skillContent').value = '';
|
||||
document.getElementById('skillName').disabled = false;
|
||||
}
|
||||
|
||||
function hideSkillAddForm() {
|
||||
document.getElementById('skillsList').style.display = '';
|
||||
document.getElementById('skillsMarketplace').style.display = 'block';
|
||||
document.getElementById('skillAddForm').style.display = 'none';
|
||||
loadInstalledSkills();
|
||||
}
|
||||
|
||||
function saveSkill() {
|
||||
var name = document.getElementById('skillName').value.trim();
|
||||
var description = document.getElementById('skillDescription').value.trim();
|
||||
var scope = document.getElementById('skillScope').value;
|
||||
var content = document.getElementById('skillContent').value;
|
||||
|
||||
if (!name) return;
|
||||
|
||||
// Build SKILL.md content
|
||||
var skillMd = '---\\n';
|
||||
skillMd += 'name: ' + name + '\\n';
|
||||
if (description) {
|
||||
skillMd += 'description: ' + description + '\\n';
|
||||
}
|
||||
skillMd += '---\\n\\n';
|
||||
skillMd += content || '';
|
||||
|
||||
vscode.postMessage({
|
||||
type: 'saveSkill',
|
||||
name: name,
|
||||
scope: scope,
|
||||
content: skillMd
|
||||
});
|
||||
|
||||
hideSkillAddForm();
|
||||
}
|
||||
|
||||
function deleteSkill(name, scope) {
|
||||
vscode.postMessage({
|
||||
type: 'deleteSkill',
|
||||
name: name,
|
||||
scope: scope
|
||||
});
|
||||
}
|
||||
|
||||
function searchSkills(query) {
|
||||
clearTimeout(skillsSearchTimeout);
|
||||
skillsSearchTimeout = setTimeout(function() {
|
||||
if (!query || query.length < 2) {
|
||||
renderFeaturedSkills(topSkills);
|
||||
return;
|
||||
}
|
||||
// Filter featured locally first
|
||||
var q = query.toLowerCase();
|
||||
var local = topSkills.filter(function(s) {
|
||||
return (s.name && s.name.toLowerCase().indexOf(q) >= 0) ||
|
||||
(s.source && s.source.toLowerCase().indexOf(q) >= 0);
|
||||
});
|
||||
if (local.length > 0) {
|
||||
renderFeaturedSkills(local);
|
||||
} else {
|
||||
var grid = document.getElementById('skillsGrid');
|
||||
grid.innerHTML = '<div class="marketplace-loading">Searching...</div>';
|
||||
}
|
||||
// Also search API
|
||||
vscode.postMessage({ type: 'searchSkills', query: query });
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function handleSkillsSearchResponse(data) {
|
||||
var grid = document.getElementById('skillsGrid');
|
||||
if (!grid) return;
|
||||
|
||||
var skills = data.skills || [];
|
||||
if (skills.length === 0) {
|
||||
grid.innerHTML = '<div class="marketplace-loading">No skills found.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
skills.forEach(function(skill) {
|
||||
var name = skill.name || skill.skillId || 'Unknown';
|
||||
var installs = skill.installs || 0;
|
||||
var source = skill.source || '';
|
||||
var safeId = escapeHtml(skill.id || name).replace(/'/g, ''');
|
||||
|
||||
var installsHtml = installs > 0 ? '<span class="marketplace-item-stars">' + (installs >= 1000 ? (Math.round(installs / 100) / 10) + 'k' : installs) + ' installs</span>' : '';
|
||||
|
||||
var rawUrl = skill.rawUrl || '';
|
||||
var installsText = installs >= 1000 ? (Math.round(installs / 100) / 10) + 'k installs' : (installs > 0 ? installs + ' installs' : '');
|
||||
html += '<div class="marketplace-item" data-skill-id="' + safeId + '" data-skill-source="' + escapeHtml(source) + '" data-skill-name="' + escapeHtml(name) + '" data-skill-rawurl="' + escapeHtml(rawUrl) + '" data-skill-installs="' + escapeHtml(installsText) + '" onclick="installSkillFromMarketplace(this)">' +
|
||||
'<div class="marketplace-item-header">' +
|
||||
'<div class="marketplace-item-icon-placeholder">' + escapeHtml(name.charAt(0).toUpperCase()) + '</div>' +
|
||||
'<div class="marketplace-item-info">' +
|
||||
'<div class="marketplace-item-name">' + escapeHtml(name) + '</div>' +
|
||||
'<div class="marketplace-item-meta">' + installsHtml + '</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="marketplace-item-desc">' + escapeHtml(source) + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
grid.innerHTML = html;
|
||||
}
|
||||
|
||||
var skillsDisplayedList = null;
|
||||
|
||||
function installSkillFromMarketplace(el) {
|
||||
var source = el.dataset.skillSource;
|
||||
var name = el.dataset.skillName;
|
||||
var installs = el.dataset.skillInstalls || '';
|
||||
|
||||
if (!source || !name) return;
|
||||
|
||||
var repoUrl = 'https://github.com/' + source.replace(/^github\\//, '');
|
||||
var installsHtml = installs ? '<span class="marketplace-item-stars">' + installs + '</span>' : '';
|
||||
|
||||
var grid = document.getElementById('skillsGrid');
|
||||
// Save current grid content to restore on back
|
||||
skillsDisplayedList = grid.innerHTML;
|
||||
|
||||
grid.innerHTML = '<div class="marketplace-detail">' +
|
||||
'<button class="marketplace-back-btn" onclick="backToSkillsList()">← Back</button>' +
|
||||
'<div class="marketplace-detail-header">' +
|
||||
'<div class="marketplace-item-icon-placeholder" style="width:40px;height:40px;font-size:18px;">' + escapeHtml(name.charAt(0).toUpperCase()) + '</div>' +
|
||||
'<div class="marketplace-detail-header-info">' +
|
||||
'<div class="marketplace-detail-name">' + escapeHtml(name) + '</div>' +
|
||||
'<div class="marketplace-detail-header-meta">' +
|
||||
installsHtml +
|
||||
'<a href="' + escapeHtml(repoUrl) + '" target="_blank" class="marketplace-detail-link">GitHub</a>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="marketplace-detail-desc">' + escapeHtml('Source: ' + source) + '</div>' +
|
||||
'<div class="marketplace-detail-config">' +
|
||||
'<div class="marketplace-detail-section-title">Install to</div>' +
|
||||
'<div class="form-group" style="margin:0;">' +
|
||||
'<select id="skillInstallScope">' +
|
||||
'<option value="project">Project (.claude/skills/)</option>' +
|
||||
'<option value="global">Global (~/.claude/skills/)</option>' +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="marketplace-detail-actions" style="margin-top:12px;">' +
|
||||
'<button class="btn" data-source="' + escapeHtml(source) + '" data-name="' + escapeHtml(name) + '" onclick="confirmSkillInstall(this)">Install</button>' +
|
||||
'<div style="font-size:11px;color:var(--vscode-descriptionForeground);margin-top:6px;">Opens a terminal running <code style="font-size:10px;">npx skills add</code> via <a href="https://skills.sh" target="_blank" class="marketplace-detail-link">skills.sh</a></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function backToSkillsList() {
|
||||
var grid = document.getElementById('skillsGrid');
|
||||
if (skillsDisplayedList) {
|
||||
grid.innerHTML = skillsDisplayedList;
|
||||
} else {
|
||||
renderFeaturedSkills(topSkills);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSkillDetail(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function confirmSkillInstall(btn) {
|
||||
var source = btn.dataset.source;
|
||||
var name = btn.dataset.name;
|
||||
sendStats('Skill installed', { name: name, source: source });
|
||||
var scope = document.getElementById('skillInstallScope').value;
|
||||
|
||||
var repoUrl = 'https://github.com/' + source.replace(/^github\\//, '');
|
||||
var command = 'npx -y skills add ' + repoUrl + ' --skill ' + name + ' --agent claude-code -y';
|
||||
if (scope === 'global') {
|
||||
command += ' --global';
|
||||
}
|
||||
|
||||
vscode.postMessage({
|
||||
type: 'runTerminalCommand',
|
||||
command: command
|
||||
});
|
||||
|
||||
hideSkillsModal();
|
||||
}
|
||||
`;
|
||||
|
||||
export default getSkillsScript;
|
||||
51
src/skills-ui.ts
Normal file
51
src/skills-ui.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
const getSkillsHtml = () => `
|
||||
<!-- Skills modal -->
|
||||
<div id="skillsModal" class="tools-modal" style="display: none;">
|
||||
<div class="tools-modal-content">
|
||||
<div class="tools-modal-header">
|
||||
<span>Skills</span>
|
||||
<button class="tools-close-btn" onclick="hideSkillsModal()">✕</button>
|
||||
</div>
|
||||
<div class="tools-list">
|
||||
<div class="mcp-servers-list" id="skillsList">
|
||||
<!-- Installed skills will be loaded here -->
|
||||
</div>
|
||||
<div class="mcp-popular-servers" id="skillsMarketplace">
|
||||
<h4>Search Skills</h4>
|
||||
<div class="marketplace-search">
|
||||
<input type="text" id="skillsSearch" placeholder="Search skills..." oninput="searchSkills(this.value)" />
|
||||
</div>
|
||||
<div class="marketplace-grid" id="skillsGrid">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mcp-add-form" id="skillAddForm" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label for="skillName">Skill Name:</label>
|
||||
<input type="text" id="skillName" placeholder="my-skill" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="skillDescription">Description:</label>
|
||||
<input type="text" id="skillDescription" placeholder="What this skill does">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="skillScope">Scope:</label>
|
||||
<select id="skillScope">
|
||||
<option value="personal">Personal (~/.claude/skills/)</option>
|
||||
<option value="project">Project (.claude/skills/)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="skillContent">Instructions (Markdown):</label>
|
||||
<textarea id="skillContent" placeholder="Instructions for Claude to follow when this skill is invoked..." rows="8"></textarea>
|
||||
</div>
|
||||
<div class="form-buttons">
|
||||
<button class="btn" onclick="saveSkill()">Create Skill</button>
|
||||
<button class="btn outlined" onclick="hideSkillAddForm()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export default getSkillsHtml;
|
||||
203
src/test/downloader.integration.test.ts
Normal file
203
src/test/downloader.integration.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
// End-to-end integration tests for the claude downloader.
|
||||
//
|
||||
// These hit the REAL npm registry and REAL Anthropic CDN and download the REAL
|
||||
// native binary to a temp directory. They are slow (~60MB–213MB of transfer)
|
||||
// and network-dependent. If the suite is ever run in a CI environment without
|
||||
// egress, these will fail with NETWORK — mark them .skip() if you need to.
|
||||
//
|
||||
// We never EXECUTE the downloaded binary. We just verify:
|
||||
// - the downloader returns a sensible result
|
||||
// - the file exists at the expected path with mode 755 (on Unix)
|
||||
// - the file starts with a platform-appropriate executable magic number
|
||||
// - the integrity hash matched (implicit — the downloader would throw if not)
|
||||
|
||||
import * as assert from 'assert';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { detectPlatform, downloadClaude, DownloaderError } from '../claudeDownloader';
|
||||
|
||||
const INTEGRATION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes — 213MB on slow networks
|
||||
|
||||
function mkTempDir(prefix: string): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix + '-'));
|
||||
}
|
||||
|
||||
function rmRf(dir: string): void {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
// Check the first few bytes of the binary match the expected executable format.
|
||||
// We never parse further than the magic — just enough to confirm we wrote out an
|
||||
// actual executable and not e.g. an HTML error page or a README.
|
||||
function assertExecutableMagic(binaryPath: string): void {
|
||||
const fd = fs.openSync(binaryPath, 'r');
|
||||
const buf = Buffer.alloc(4);
|
||||
fs.readSync(fd, buf, 0, 4, 0);
|
||||
fs.closeSync(fd);
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
// Mach-O magic numbers: MH_MAGIC_64 (0xFEEDFACF) or MH_CIGAM_64 (0xCFFAEDFE)
|
||||
// or fat universal binary (0xCAFEBABE / 0xBEBAFECA).
|
||||
const m = buf.readUInt32BE(0);
|
||||
const lm = buf.readUInt32LE(0);
|
||||
assert.ok(
|
||||
m === 0xFEEDFACF || m === 0xCAFEBABE || lm === 0xFEEDFACF || lm === 0xCAFEBABE,
|
||||
`Not a Mach-O binary — got magic 0x${m.toString(16)} (LE 0x${lm.toString(16)})`,
|
||||
);
|
||||
} else if (process.platform === 'linux') {
|
||||
// ELF: 0x7F 'E' 'L' 'F'
|
||||
assert.strictEqual(buf[0], 0x7F);
|
||||
assert.strictEqual(buf[1], 0x45);
|
||||
assert.strictEqual(buf[2], 0x4C);
|
||||
assert.strictEqual(buf[3], 0x46);
|
||||
} else if (process.platform === 'win32') {
|
||||
// PE: starts with MZ (DOS stub).
|
||||
assert.strictEqual(buf[0], 0x4D);
|
||||
assert.strictEqual(buf[1], 0x5A);
|
||||
}
|
||||
}
|
||||
|
||||
suite('claudeDownloader: integration (real network)', function () {
|
||||
// Skip entirely on unsupported platforms — integration only makes sense where
|
||||
// a binary is published for us.
|
||||
const platform = detectPlatform();
|
||||
if (!platform) {
|
||||
test.skip('no supported binary for this platform', () => { /* skipped */ });
|
||||
return;
|
||||
}
|
||||
|
||||
this.timeout(INTEGRATION_TIMEOUT_MS);
|
||||
|
||||
let tempDirs: string[] = [];
|
||||
|
||||
teardown(() => {
|
||||
for (const d of tempDirs) {rmRf(d);}
|
||||
tempDirs = [];
|
||||
});
|
||||
|
||||
test('downloads the real binary from npm and verifies integrity', async () => {
|
||||
const dest = mkTempDir('claude-dl-npm');
|
||||
tempDirs.push(dest);
|
||||
|
||||
const progressPhases: string[] = [];
|
||||
const result = await downloadClaude({
|
||||
destDir: dest,
|
||||
onProgress: (p) => {
|
||||
if (!progressPhases.includes(p.phase)) {progressPhases.push(p.phase);}
|
||||
},
|
||||
});
|
||||
|
||||
// Happy path should go npm, no fallback.
|
||||
assert.strictEqual(result.source, 'npm');
|
||||
assert.ok(/^\d+\.\d+\.\d+/.test(result.version), 'version looks unfamiliar: ' + result.version);
|
||||
assert.ok(result.bytesDownloaded > 1_000_000, 'tarball was suspiciously small: ' + result.bytesDownloaded);
|
||||
|
||||
// File is at the expected path with correct permissions.
|
||||
const expectedPath = path.join(dest, platform.binaryName);
|
||||
assert.strictEqual(result.binaryPath, expectedPath);
|
||||
assert.ok(fs.existsSync(result.binaryPath));
|
||||
const stat = fs.statSync(result.binaryPath);
|
||||
if (process.platform !== 'win32') {
|
||||
|
||||
assert.strictEqual(stat.mode & 0o777, 0o755, 'expected chmod 755');
|
||||
}
|
||||
assert.ok(stat.size > 50_000_000, 'extracted binary is suspiciously small: ' + stat.size);
|
||||
|
||||
assertExecutableMagic(result.binaryPath);
|
||||
|
||||
// Progress pipeline actually fired phase transitions.
|
||||
assert.ok(progressPhases.includes('resolving'), 'missing resolving phase');
|
||||
assert.ok(progressPhases.includes('downloading'), 'missing downloading phase');
|
||||
assert.ok(progressPhases.includes('verifying'), 'missing verifying phase');
|
||||
assert.ok(progressPhases.includes('installing'), 'missing installing phase');
|
||||
assert.ok(!progressPhases.includes('fallback'), 'fallback phase fired unexpectedly');
|
||||
});
|
||||
|
||||
test('falls back to CDN when npm is unreachable', async () => {
|
||||
const dest = mkTempDir('claude-dl-fallback');
|
||||
tempDirs.push(dest);
|
||||
|
||||
const progressPhases: string[] = [];
|
||||
const result = await downloadClaude({
|
||||
destDir: dest,
|
||||
// Point npm at a loopback port that actively refuses connections so
|
||||
// the npm path fails fast (ECONNREFUSED). CDN override is left at
|
||||
// default so it hits the real Anthropic CDN.
|
||||
npmRegistry: 'http://127.0.0.1:1',
|
||||
onProgress: (p) => {
|
||||
if (!progressPhases.includes(p.phase)) {progressPhases.push(p.phase);}
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(result.source, 'cdn');
|
||||
assert.ok(/^\d+\.\d+\.\d+/.test(result.version));
|
||||
assert.ok(result.bytesDownloaded > 50_000_000, 'CDN serves uncompressed ≥50MB: ' + result.bytesDownloaded);
|
||||
assert.ok(fs.existsSync(result.binaryPath));
|
||||
assertExecutableMagic(result.binaryPath);
|
||||
assert.ok(progressPhases.includes('fallback'), 'expected fallback phase after npm failure');
|
||||
});
|
||||
|
||||
test('AGGREGATE error when both sources are unreachable', async () => {
|
||||
const dest = mkTempDir('claude-dl-aggregate');
|
||||
tempDirs.push(dest);
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await downloadClaude({
|
||||
destDir: dest,
|
||||
npmRegistry: 'http://127.0.0.1:1',
|
||||
cdnBase: 'http://127.0.0.1:1',
|
||||
});
|
||||
assert.fail('expected both-sources-fail to throw');
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
|
||||
assert.ok(caught instanceof DownloaderError, 'expected DownloaderError');
|
||||
const e = caught as DownloaderError;
|
||||
assert.strictEqual(e.code, 'AGGREGATE');
|
||||
assert.ok(e.details, 'AGGREGATE should carry details');
|
||||
assert.ok(typeof e.details!.npmCode === 'string', 'npmCode should be populated');
|
||||
assert.ok(typeof e.details!.cdnCode === 'string', 'cdnCode should be populated');
|
||||
// Should not leak any path from the local temp dir.
|
||||
assert.ok(!e.message.includes(os.homedir()), 'error message leaks home dir');
|
||||
assert.ok(!e.message.includes(dest), 'error message leaks temp dir');
|
||||
|
||||
// Temp file should be cleaned up — nothing left in dest except the dir itself.
|
||||
const entries = fs.readdirSync(dest);
|
||||
assert.deepStrictEqual(entries, [], 'temp download files were not cleaned up');
|
||||
});
|
||||
|
||||
test('INTEGRITY error when CDN manifest is tampered (simulated via bogus CDN base)', async () => {
|
||||
const dest = mkTempDir('claude-dl-bad-cdn');
|
||||
tempDirs.push(dest);
|
||||
|
||||
// npm still works so we actually need to disable it to force CDN.
|
||||
// Point CDN at a non-existent but reachable-looking host — expect
|
||||
// NETWORK (DNS failure) bubbled through AGGREGATE, not INTEGRITY.
|
||||
// This is really just confirming error classification is coherent when
|
||||
// the CDN hostname resolves but returns nonsense — skip the exact
|
||||
// INTEGRITY path since we'd need to stand up a mock server. This test
|
||||
// doubles as a sanity check on the AGGREGATE error formatting.
|
||||
let caught: unknown;
|
||||
try {
|
||||
await downloadClaude({
|
||||
destDir: dest,
|
||||
npmRegistry: 'http://127.0.0.1:1',
|
||||
cdnBase: 'http://127.0.0.1:1',
|
||||
});
|
||||
assert.fail('expected failure');
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
const e = caught as DownloaderError;
|
||||
assert.strictEqual(e.code, 'AGGREGATE');
|
||||
assert.ok(
|
||||
e.message.includes('npm:') && e.message.includes('cdn:'),
|
||||
'AGGREGATE message should name both sources',
|
||||
);
|
||||
});
|
||||
});
|
||||
312
src/test/downloader.test.ts
Normal file
312
src/test/downloader.test.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import * as assert from 'assert';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { detectPlatform, DownloaderError, __test__ } from '../claudeDownloader';
|
||||
|
||||
const { parseOctal, readTarHeader, processTarChunk, errCode, safeProgress } = __test__;
|
||||
|
||||
// ---- Tar fixture builder -----------------------------------------------
|
||||
// Build a ustar header block + aligned data. We only populate the fields our
|
||||
// parser reads: name (0..100), size (124..136), typeflag (156), prefix (345..500).
|
||||
|
||||
function makeHeader(name: string, size: number, opts: { typeFlag?: string; prefix?: string } = {}): Buffer {
|
||||
const block = Buffer.alloc(512, 0);
|
||||
block.write(name, 0, 100, 'utf8');
|
||||
// Size: octal ASCII, null-terminated, 11 digits + NUL.
|
||||
const oct = size.toString(8).padStart(11, '0');
|
||||
block.write(oct, 124, 11, 'ascii');
|
||||
block[135] = 0;
|
||||
block[156] = (opts.typeFlag || '0').charCodeAt(0);
|
||||
if (opts.prefix) {block.write(opts.prefix, 345, 155, 'utf8');}
|
||||
return block;
|
||||
}
|
||||
|
||||
function makeMalformedSizeHeader(name: string): Buffer {
|
||||
// Non-octal junk in the size field.
|
||||
const block = Buffer.alloc(512, 0);
|
||||
block.write(name, 0, 100, 'utf8');
|
||||
block.write('ZZZ', 124, 3, 'ascii');
|
||||
block[156] = '0'.charCodeAt(0);
|
||||
return block;
|
||||
}
|
||||
|
||||
function paddedData(size: number, fill = 0x41 /* 'A' */): Buffer {
|
||||
const padded = Math.ceil(size / 512) * 512;
|
||||
const buf = Buffer.alloc(padded, 0);
|
||||
for (let i = 0; i < size; i++) {buf[i] = fill;}
|
||||
return buf;
|
||||
}
|
||||
|
||||
function buildTarball(entries: Array<{ name: string; data: Buffer; typeFlag?: string; prefix?: string }>): Buffer {
|
||||
const parts: Buffer[] = [];
|
||||
for (const e of entries) {
|
||||
parts.push(makeHeader(e.name, e.data.length, { typeFlag: e.typeFlag, prefix: e.prefix }));
|
||||
const padded = Math.ceil(e.data.length / 512) * 512;
|
||||
const padBlock = Buffer.alloc(padded, 0);
|
||||
e.data.copy(padBlock, 0);
|
||||
parts.push(padBlock);
|
||||
}
|
||||
// End-of-archive: two zero-blocks.
|
||||
parts.push(Buffer.alloc(1024, 0));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
function newWriteStream(): { stream: fs.WriteStream; path: string; read(): Buffer } {
|
||||
const tmp = path.join(os.tmpdir(), 'downloader-test-' + process.pid + '-' + Date.now() + '-' + Math.random().toString(36).slice(2));
|
||||
const stream = fs.createWriteStream(tmp);
|
||||
return {
|
||||
stream,
|
||||
path: tmp,
|
||||
read: () => fs.readFileSync(tmp),
|
||||
};
|
||||
}
|
||||
|
||||
async function flushWriteStream(s: fs.WriteStream): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
s.end(() => resolve());
|
||||
s.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
suite('claudeDownloader: detectPlatform', () => {
|
||||
test('returns a supported platform shape on the current host', () => {
|
||||
const p = detectPlatform();
|
||||
if (!p) {
|
||||
// Test suite only runs on supported hosts — skip if we land somewhere weird.
|
||||
return;
|
||||
}
|
||||
assert.strictEqual(typeof p.key, 'string');
|
||||
assert.ok(p.key.length > 0);
|
||||
assert.ok(
|
||||
/^(darwin|linux|win32)-(x64|arm64)(-musl)?$/.test(p.key),
|
||||
'unexpected platform key: ' + p.key,
|
||||
);
|
||||
assert.ok(p.binaryName === 'claude' || p.binaryName === 'claude.exe');
|
||||
assert.ok(p.tarEntry === 'package/claude' || p.tarEntry === 'package/claude.exe');
|
||||
// Windows → .exe, others → no extension
|
||||
if (process.platform === 'win32') {
|
||||
assert.strictEqual(p.binaryName, 'claude.exe');
|
||||
assert.strictEqual(p.tarEntry, 'package/claude.exe');
|
||||
} else {
|
||||
assert.strictEqual(p.binaryName, 'claude');
|
||||
assert.strictEqual(p.tarEntry, 'package/claude');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
suite('claudeDownloader: DownloaderError', () => {
|
||||
test('exposes code, message, details, cause', () => {
|
||||
const cause = new Error('underlying');
|
||||
const e = new DownloaderError('NETWORK', 'something failed', { status: 503, host: 'example.com' }, cause);
|
||||
assert.strictEqual(e.code, 'NETWORK');
|
||||
assert.strictEqual(e.message, 'something failed');
|
||||
assert.deepStrictEqual(e.details, { status: 503, host: 'example.com' });
|
||||
assert.strictEqual(e.cause, cause);
|
||||
assert.strictEqual(e.name, 'DownloaderError');
|
||||
assert.ok(e instanceof Error);
|
||||
});
|
||||
|
||||
test('details and cause are optional', () => {
|
||||
const e = new DownloaderError('CANCELLED', 'stop');
|
||||
assert.strictEqual(e.details, undefined);
|
||||
assert.strictEqual(e.cause, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
suite('claudeDownloader: parseOctal', () => {
|
||||
test('parses standard octal size', () => {
|
||||
const buf = Buffer.alloc(12, 0);
|
||||
buf.write('00000001024', 0, 'ascii'); // 1024 in octal
|
||||
assert.strictEqual(parseOctal(buf), 0o1024);
|
||||
});
|
||||
|
||||
test('parses octal with trailing NUL terminator', () => {
|
||||
const buf = Buffer.alloc(12, 0);
|
||||
buf.write('0000100', 0, 'ascii');
|
||||
assert.strictEqual(parseOctal(buf), 0o100);
|
||||
});
|
||||
|
||||
test('parses octal with trailing space terminator', () => {
|
||||
const buf = Buffer.from('0000100 \0\0\0\0\0');
|
||||
assert.strictEqual(parseOctal(buf), 0o100);
|
||||
});
|
||||
|
||||
test('returns 0 for empty buffer', () => {
|
||||
assert.strictEqual(parseOctal(Buffer.alloc(12, 0)), 0);
|
||||
});
|
||||
|
||||
test('returns NaN for non-octal garbage', () => {
|
||||
const buf = Buffer.from('ZZZ\0\0\0\0\0\0\0\0\0');
|
||||
const result = parseOctal(buf);
|
||||
assert.ok(Number.isNaN(result), 'expected NaN for non-octal input, got ' + result);
|
||||
});
|
||||
});
|
||||
|
||||
suite('claudeDownloader: readTarHeader', () => {
|
||||
test('reads a well-formed header', () => {
|
||||
const hdr = makeHeader('package/claude', 1024);
|
||||
const parsed = readTarHeader(hdr);
|
||||
assert.strictEqual(parsed.name, 'package/claude');
|
||||
assert.strictEqual(parsed.size, 1024);
|
||||
assert.strictEqual(parsed.isRegularFile, true);
|
||||
});
|
||||
|
||||
test('combines prefix + name for long paths', () => {
|
||||
const hdr = makeHeader('claude', 512, { prefix: 'package' });
|
||||
const parsed = readTarHeader(hdr);
|
||||
assert.strictEqual(parsed.name, 'package/claude');
|
||||
});
|
||||
|
||||
test('flags non-regular entries (directory)', () => {
|
||||
const hdr = makeHeader('package/', 0, { typeFlag: '5' });
|
||||
const parsed = readTarHeader(hdr);
|
||||
assert.strictEqual(parsed.isRegularFile, false);
|
||||
});
|
||||
|
||||
test('returns size=-1 when size field is garbage (NaN guard)', () => {
|
||||
const hdr = makeMalformedSizeHeader('package/claude');
|
||||
const parsed = readTarHeader(hdr);
|
||||
assert.strictEqual(parsed.size, -1, 'malformed size must be clamped to -1');
|
||||
});
|
||||
});
|
||||
|
||||
suite('claudeDownloader: processTarChunk', () => {
|
||||
test('extracts a single matching entry', async () => {
|
||||
const data = paddedData(2000, 0x42 /* 'B' */);
|
||||
const binary = data.subarray(0, 2000);
|
||||
const tar = buildTarball([{ name: 'package/claude', data: binary }]);
|
||||
|
||||
const ws = newWriteStream();
|
||||
const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 };
|
||||
processTarChunk(state, tar, 'package/claude', ws.stream);
|
||||
await flushWriteStream(ws.stream);
|
||||
|
||||
const out = ws.read();
|
||||
assert.strictEqual(state.found, true);
|
||||
assert.strictEqual(out.length, 2000);
|
||||
assert.deepStrictEqual(out, binary);
|
||||
fs.unlinkSync(ws.path);
|
||||
});
|
||||
|
||||
test('skips non-matching entries and still extracts target', async () => {
|
||||
const decoy = Buffer.from('ignore me');
|
||||
const binary = paddedData(1500, 0x43 /* 'C' */).subarray(0, 1500);
|
||||
const tar = buildTarball([
|
||||
{ name: 'package/README.md', data: decoy },
|
||||
{ name: 'package/claude', data: binary },
|
||||
{ name: 'package/LICENSE', data: Buffer.from('also ignore') },
|
||||
]);
|
||||
|
||||
const ws = newWriteStream();
|
||||
const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 };
|
||||
processTarChunk(state, tar, 'package/claude', ws.stream);
|
||||
await flushWriteStream(ws.stream);
|
||||
|
||||
assert.strictEqual(state.found, true);
|
||||
assert.deepStrictEqual(ws.read(), binary);
|
||||
fs.unlinkSync(ws.path);
|
||||
});
|
||||
|
||||
test('handles headers split across multiple chunks', async () => {
|
||||
const binary = paddedData(3000, 0x44 /* 'D' */).subarray(0, 3000);
|
||||
const tar = buildTarball([
|
||||
{ name: 'package/README.md', data: Buffer.from('meh') },
|
||||
{ name: 'package/claude', data: binary },
|
||||
]);
|
||||
|
||||
const ws = newWriteStream();
|
||||
const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 };
|
||||
// Drip-feed 137-byte chunks — guaranteed to bisect every header + data block.
|
||||
const chunkSize = 137;
|
||||
for (let i = 0; i < tar.length; i += chunkSize) {
|
||||
processTarChunk(state, tar.subarray(i, Math.min(i + chunkSize, tar.length)), 'package/claude', ws.stream);
|
||||
}
|
||||
await flushWriteStream(ws.stream);
|
||||
|
||||
assert.strictEqual(state.found, true);
|
||||
assert.deepStrictEqual(ws.read(), binary);
|
||||
fs.unlinkSync(ws.path);
|
||||
});
|
||||
|
||||
test('sets found=false when target entry is absent', async () => {
|
||||
const tar = buildTarball([
|
||||
{ name: 'package/README.md', data: Buffer.from('nope') },
|
||||
]);
|
||||
|
||||
const ws = newWriteStream();
|
||||
const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 };
|
||||
processTarChunk(state, tar, 'package/claude', ws.stream);
|
||||
await flushWriteStream(ws.stream);
|
||||
|
||||
assert.strictEqual(state.found, false);
|
||||
assert.strictEqual(ws.read().length, 0);
|
||||
fs.unlinkSync(ws.path);
|
||||
});
|
||||
|
||||
test('throws INTEGRITY on malformed size in header', () => {
|
||||
const bad = makeMalformedSizeHeader('package/evil');
|
||||
const endBlocks = Buffer.alloc(1024, 0);
|
||||
const tar = Buffer.concat([bad, endBlocks]);
|
||||
|
||||
const ws = newWriteStream();
|
||||
const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 };
|
||||
assert.throws(
|
||||
() => processTarChunk(state, tar, 'package/claude', ws.stream),
|
||||
(err) => err instanceof DownloaderError && err.code === 'INTEGRITY',
|
||||
);
|
||||
ws.stream.destroy();
|
||||
try { fs.unlinkSync(ws.path); } catch { /* best effort */ }
|
||||
});
|
||||
|
||||
test('stops cleanly at end-of-archive zero block', async () => {
|
||||
const binary = paddedData(800, 0x45 /* 'E' */).subarray(0, 800);
|
||||
const tar = buildTarball([{ name: 'package/claude', data: binary }]);
|
||||
|
||||
const ws = newWriteStream();
|
||||
const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 };
|
||||
processTarChunk(state, tar, 'package/claude', ws.stream);
|
||||
await flushWriteStream(ws.stream);
|
||||
|
||||
assert.strictEqual(state.found, true);
|
||||
assert.deepStrictEqual(ws.read(), binary);
|
||||
fs.unlinkSync(ws.path);
|
||||
});
|
||||
});
|
||||
|
||||
suite('claudeDownloader: errCode helper', () => {
|
||||
test('extracts .code when present', () => {
|
||||
const err = Object.assign(new Error('x'), { code: 'EACCES' });
|
||||
assert.strictEqual(errCode(err, 'FALLBACK'), 'EACCES');
|
||||
});
|
||||
|
||||
test('returns fallback when code is absent', () => {
|
||||
assert.strictEqual(errCode(new Error('x'), 'FALLBACK'), 'FALLBACK');
|
||||
assert.strictEqual(errCode(null, 'FALLBACK'), 'FALLBACK');
|
||||
assert.strictEqual(errCode(undefined, 'FALLBACK'), 'FALLBACK');
|
||||
assert.strictEqual(errCode('just a string', 'FALLBACK'), 'FALLBACK');
|
||||
});
|
||||
|
||||
test('returns fallback when code is a non-string', () => {
|
||||
assert.strictEqual(errCode({ code: 123 }, 'FALLBACK'), 'FALLBACK');
|
||||
assert.strictEqual(errCode({ code: '' }, 'FALLBACK'), 'FALLBACK');
|
||||
});
|
||||
});
|
||||
|
||||
suite('claudeDownloader: safeProgress helper', () => {
|
||||
test('invokes the callback with the progress payload', () => {
|
||||
const calls: unknown[] = [];
|
||||
safeProgress((p) => calls.push(p), { phase: 'resolving' });
|
||||
assert.deepStrictEqual(calls, [{ phase: 'resolving' }]);
|
||||
});
|
||||
|
||||
test('swallows callback throws without propagating', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
safeProgress(() => { throw new Error('boom'); }, { phase: 'downloading' });
|
||||
});
|
||||
});
|
||||
|
||||
test('handles undefined callback', () => {
|
||||
assert.doesNotThrow(() => safeProgress(undefined, { phase: 'verifying' }));
|
||||
});
|
||||
});
|
||||
479
src/top-mcp-servers.json
Normal file
479
src/top-mcp-servers.json
Normal file
@@ -0,0 +1,479 @@
|
||||
[
|
||||
{
|
||||
"id": "sequential-thinking",
|
||||
"name": "Sequential Thinking",
|
||||
"description": "Step-by-step reasoning capabilities",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "",
|
||||
"installType": "npm",
|
||||
"installConfig": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-sequential-thinking"
|
||||
]
|
||||
},
|
||||
"featured": true
|
||||
},
|
||||
{
|
||||
"id": "memory",
|
||||
"name": "Memory",
|
||||
"description": "Knowledge graph storage",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "",
|
||||
"installType": "npm",
|
||||
"installConfig": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-memory"
|
||||
]
|
||||
},
|
||||
"featured": true
|
||||
},
|
||||
{
|
||||
"id": "puppeteer",
|
||||
"name": "Puppeteer",
|
||||
"description": "Browser automation",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "",
|
||||
"installType": "npm",
|
||||
"installConfig": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-puppeteer"
|
||||
]
|
||||
},
|
||||
"featured": true
|
||||
},
|
||||
{
|
||||
"id": "fetch",
|
||||
"name": "Fetch",
|
||||
"description": "HTTP requests & web scraping",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "",
|
||||
"installType": "npm",
|
||||
"installConfig": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-fetch"
|
||||
]
|
||||
},
|
||||
"featured": true
|
||||
},
|
||||
{
|
||||
"id": "filesystem",
|
||||
"name": "Filesystem",
|
||||
"description": "File operations & management",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "",
|
||||
"installType": "npm",
|
||||
"installConfig": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem"
|
||||
]
|
||||
},
|
||||
"featured": true
|
||||
},
|
||||
{
|
||||
"id": "io.github.upstash/context7",
|
||||
"name": "Context7",
|
||||
"description": "Up-to-date code docs for any prompt",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://github.com/upstash/context7",
|
||||
"installType": "npm",
|
||||
"installConfig": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@upstash/context7-mcp"
|
||||
],
|
||||
"env": {
|
||||
"CONTEXT7_API_KEY": ""
|
||||
}
|
||||
},
|
||||
"featured": true
|
||||
},
|
||||
{
|
||||
"id": "com.airtable/mcp",
|
||||
"name": "Airtable",
|
||||
"description": "Official Airtable MCP server for managing bases, tables, and records.",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "",
|
||||
"installType": "http",
|
||||
"installConfig": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.airtable.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "com.apify/mcp",
|
||||
"name": "Apify",
|
||||
"description": "Extract data from social media, search engines, maps, e-commerce sites, and any website using thousands of ready-made tools from Apify Store.",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://github.com/apify/apify-mcp-server",
|
||||
"installType": "http",
|
||||
"installConfig": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.apify.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "io.github.browserbase/mcp-server-browserbase",
|
||||
"name": "Browserbase",
|
||||
"description": "MCP server for AI web browser automation using Browserbase and Stagehand",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://github.com/browserbase/mcp-server-browserbase",
|
||||
"installType": "npm",
|
||||
"installConfig": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@browserbasehq/mcp-server-browserbase"
|
||||
],
|
||||
"env": {
|
||||
"BROWSERBASE_API_KEY": "",
|
||||
"BROWSERBASE_PROJECT_ID": "",
|
||||
"GEMINI_API_KEY": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "io.github.clerk/mcp-server",
|
||||
"name": "Clerk",
|
||||
"description": "Access Clerk authentication docs, SDK snippets, and quickstart guides",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server",
|
||||
"installType": "http",
|
||||
"installConfig": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.clerk.com/mcp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "com.cloudflare.mcp/mcp",
|
||||
"name": "Cloudflare",
|
||||
"description": "Cloudflare MCP servers",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://github.com/cloudflare/mcp-server-cloudflare",
|
||||
"installType": "http",
|
||||
"installConfig": {
|
||||
"type": "http",
|
||||
"url": "https://docs.mcp.cloudflare.com/mcp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ai.exa/mcp",
|
||||
"name": "Exa",
|
||||
"description": "Web search and code search MCP server powered by Exa",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://github.com/exa-labs/exa-mcp-server",
|
||||
"installType": "http",
|
||||
"installConfig": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.exa.ai/mcp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "com.figma/mcp",
|
||||
"name": "Figma",
|
||||
"description": "Official Figma MCP server for accessing design files, components, and design context",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://help.figma.com/hc/en-us/articles/35281350665623-Figma-MCP-collection-How-to-set-up-the-Figma-remote-MCP-server-preferred",
|
||||
"installType": "http",
|
||||
"installConfig": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.figma.com/mcp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "dev.firecrawl/mcp",
|
||||
"name": "Firecrawl",
|
||||
"description": "Web scraping, crawling, search, and structured data extraction powered by Firecrawl.",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://github.com/firecrawl/firecrawl-mcp-server",
|
||||
"installType": "http",
|
||||
"installConfig": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.firecrawl.dev/v2/mcp",
|
||||
"headers": {
|
||||
"Authorization": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "io.github.github/github-mcp-server",
|
||||
"name": "GitHub",
|
||||
"description": "Official GitHub MCP server for repos, issues, PRs, and workflows",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://github.com/github/github-mcp-server",
|
||||
"installType": "http",
|
||||
"installConfig": {
|
||||
"type": "http",
|
||||
"url": "https://api.githubcopilot.com/mcp/"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "app.linear/linear",
|
||||
"name": "Linear",
|
||||
"description": "MCP server for Linear project management and issue tracking",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "",
|
||||
"installType": "sse",
|
||||
"installConfig": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.linear.app/sse"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "com.mux/mcp",
|
||||
"name": "Mux",
|
||||
"description": "The official MCP Server for the Mux API",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://github.com/muxinc/mux-node-sdk",
|
||||
"installType": "http",
|
||||
"installConfig": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.mux.com",
|
||||
"headers": {
|
||||
"Authorization": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "com.neon/mcp",
|
||||
"name": "Neon",
|
||||
"description": "Official Neon MCP server for managing Neon projects and Postgres databases.",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://github.com/neondatabase/mcp-server-neon",
|
||||
"installType": "http",
|
||||
"installConfig": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.neon.tech/mcp",
|
||||
"headers": {
|
||||
"Authorization": "",
|
||||
"x-read-only": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "com.netlify/mcp",
|
||||
"name": "Netlify",
|
||||
"description": "Netlify's official MCP server for builds, deploys, and project management.",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://github.com/netlify/netlify-mcp",
|
||||
"installType": "npm",
|
||||
"installConfig": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@netlify/mcp"
|
||||
],
|
||||
"env": {
|
||||
"NETLIFY_PERSONAL_ACCESS_TOKEN": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "io.github.vercel/next-devtools-mcp",
|
||||
"name": "Next.js Devtools",
|
||||
"description": "Next.js development tools MCP server with stdio transport",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://github.com/vercel/next-devtools-mcp",
|
||||
"installType": "npm",
|
||||
"installConfig": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"next-devtools-mcp"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "com.notion/mcp",
|
||||
"name": "Notion",
|
||||
"description": "Official Notion MCP server",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "",
|
||||
"installType": "http",
|
||||
"installConfig": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.notion.com/mcp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "io.github.railwayapp/mcp-server",
|
||||
"name": "Railway",
|
||||
"description": "Official Railway MCP server",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://github.com/railwayapp/railway-mcp-server",
|
||||
"installType": "npm",
|
||||
"installConfig": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@railway/mcp-server"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "com.render/mcp",
|
||||
"name": "Render",
|
||||
"description": "Official Render MCP server for managing Render resources.",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://github.com/render-oss/render-mcp-server",
|
||||
"installType": "http",
|
||||
"installConfig": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.render.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "com.resend/mcp",
|
||||
"name": "Resend",
|
||||
"description": "Official Resend MCP server for email operations and audience management.",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://github.com/resend/mcp-send-email",
|
||||
"installType": "npm",
|
||||
"installConfig": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"resend-mcp"
|
||||
],
|
||||
"env": {
|
||||
"RESEND_API_KEY": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "io.sanity.www/mcp",
|
||||
"name": "Sanity",
|
||||
"description": "Direct access to your Sanity projects (content, datasets, releases, schemas) and agent rules",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://github.com/sanity-io/agent-toolkit",
|
||||
"installType": "http",
|
||||
"installConfig": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.sanity.io"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "io.github.getsentry/sentry-mcp",
|
||||
"name": "Sentry",
|
||||
"description": "MCP server for Sentry issue tracking and debugging",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://github.com/getsentry/sentry-mcp",
|
||||
"installType": "npm",
|
||||
"installConfig": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@sentry/mcp-server"
|
||||
],
|
||||
"env": {
|
||||
"SENTRY_ACCESS_TOKEN": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "com.slack/mcp",
|
||||
"name": "Slack",
|
||||
"description": "Official Slack MCP server for search, messaging, canvases, and users.",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://github.com/slackapi/slack-mcp-plugin",
|
||||
"installType": "http",
|
||||
"installConfig": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.slack.com/mcp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "com.stripe/mcp",
|
||||
"name": "Stripe",
|
||||
"description": "Official Stripe MCP server for Stripe API tools.",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://github.com/stripe/agent-toolkit",
|
||||
"installType": "http",
|
||||
"installConfig": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.stripe.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "com.supabase/mcp",
|
||||
"name": "Supabase",
|
||||
"description": "MCP server for interacting with the Supabase platform",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://github.com/supabase-community/supabase-mcp",
|
||||
"installType": "http",
|
||||
"installConfig": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.supabase.com/mcp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "com.vercel/vercel-mcp",
|
||||
"name": "Vercel",
|
||||
"description": "An MCP server for Vercel",
|
||||
"icon": "",
|
||||
"stars": 0,
|
||||
"url": "https://github.com/vercel/vercel-mcp-overview",
|
||||
"installType": "http",
|
||||
"installConfig": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.vercel.com"
|
||||
}
|
||||
}
|
||||
]
|
||||
240
src/top-plugins.json
Normal file
240
src/top-plugins.json
Normal file
@@ -0,0 +1,240 @@
|
||||
[
|
||||
{
|
||||
"name": "agent-sdk-dev",
|
||||
"description": "Claude Agent SDK Development Plugin",
|
||||
"verified": true,
|
||||
"type": "official",
|
||||
"installId": "agent-sdk-dev@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "claude-code-setup",
|
||||
"description": "Analyze codebases and recommend tailored Claude Code automations such as hooks, skills, MCP servers, and subagents.",
|
||||
"verified": true,
|
||||
"type": "official",
|
||||
"installId": "claude-code-setup@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "claude-md-management",
|
||||
"description": "Tools to maintain and improve CLAUDE.md files - audit quality, capture session learnings, and keep project memory current.",
|
||||
"verified": true,
|
||||
"type": "official",
|
||||
"installId": "claude-md-management@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "code-review",
|
||||
"description": "Automated code review for pull requests using multiple specialized agents with confidence-based scoring",
|
||||
"verified": true,
|
||||
"type": "official",
|
||||
"installId": "code-review@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "code-simplifier",
|
||||
"description": "Agent that simplifies and refines code for clarity, consistency, and maintainability while preserving functionality",
|
||||
"verified": true,
|
||||
"type": "official",
|
||||
"installId": "code-simplifier@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "commit-commands",
|
||||
"description": "Streamline your git workflow with simple commands for committing, pushing, and creating pull requests",
|
||||
"verified": true,
|
||||
"type": "official",
|
||||
"installId": "commit-commands@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "explanatory-output-style",
|
||||
"description": "Adds educational insights about implementation choices and codebase patterns (mimics the deprecated Explanatory output style)",
|
||||
"verified": true,
|
||||
"type": "official",
|
||||
"installId": "explanatory-output-style@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "feature-dev",
|
||||
"description": "Comprehensive feature development workflow with specialized agents for codebase exploration, architecture design, and quality review",
|
||||
"verified": true,
|
||||
"type": "official",
|
||||
"installId": "feature-dev@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "frontend-design",
|
||||
"description": "Frontend design skill for UI/UX implementation",
|
||||
"verified": true,
|
||||
"type": "official",
|
||||
"installId": "frontend-design@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "hookify",
|
||||
"description": "Easily create hooks to prevent unwanted behaviors by analyzing conversation patterns",
|
||||
"verified": true,
|
||||
"type": "official",
|
||||
"installId": "hookify@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "learning-output-style",
|
||||
"description": "Interactive learning mode that requests meaningful code contributions at decision points (mimics the unshipped Learning output style)",
|
||||
"verified": true,
|
||||
"type": "official",
|
||||
"installId": "learning-output-style@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "math-olympiad",
|
||||
"description": "Solve competition math (IMO, Putnam, USAMO) with adversarial verification that catches what self-verification misses. Fresh-context verifiers attack proofs with specific failure patterns. Calibrated abstention over bluffing.",
|
||||
"verified": true,
|
||||
"type": "official",
|
||||
"installId": "math-olympiad@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "mcp-server-dev",
|
||||
"description": "Skills for designing and building MCP servers that work seamlessly with Claude \u2014 guides you through deployment models (remote HTTP, MCPB, local), tool design patterns, auth, and interactive MCP apps.",
|
||||
"verified": true,
|
||||
"type": "official",
|
||||
"installId": "mcp-server-dev@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "playground",
|
||||
"description": "Creates interactive HTML playgrounds \u2014 self-contained single-file explorers with visual controls, live preview, and prompt output with copy button",
|
||||
"verified": true,
|
||||
"type": "official",
|
||||
"installId": "playground@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "plugin-dev",
|
||||
"description": "Plugin development toolkit with skills for creating agents, commands, hooks, MCP integrations, and comprehensive plugin structure guidance",
|
||||
"verified": true,
|
||||
"type": "official",
|
||||
"installId": "plugin-dev@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "pr-review-toolkit",
|
||||
"description": "Comprehensive PR review agents specializing in comments, tests, error handling, type design, code quality, and code simplification",
|
||||
"verified": true,
|
||||
"type": "official",
|
||||
"installId": "pr-review-toolkit@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "ralph-loop",
|
||||
"description": "Continuous self-referential AI loops for interactive iterative development, implementing the Ralph Wiggum technique. Run Claude in a while-true loop with the same prompt until task completion.",
|
||||
"verified": true,
|
||||
"type": "official",
|
||||
"installId": "ralph-loop@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "security-guidance",
|
||||
"description": "Security reminder hook that warns about potential security issues when editing files, including command injection, XSS, and unsafe code patterns",
|
||||
"verified": true,
|
||||
"type": "official",
|
||||
"installId": "security-guidance@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "skill-creator",
|
||||
"description": "Create new skills, improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, update or optimize an existing skill, run evals to test a skill, or benchmark skill performance with variance analysis.",
|
||||
"verified": true,
|
||||
"type": "official",
|
||||
"installId": "skill-creator@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "asana",
|
||||
"description": "Asana project management integration. Create and manage tasks, search projects, update assignments, track progress, and integrate your development workflow with Asana's work management platform.",
|
||||
"verified": false,
|
||||
"type": "external",
|
||||
"installId": "asana@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "context7",
|
||||
"description": "Upstash Context7 MCP server for up-to-date documentation lookup. Pull version-specific documentation and code examples directly from source repositories into your LLM context.",
|
||||
"verified": false,
|
||||
"type": "external",
|
||||
"installId": "context7@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "discord",
|
||||
"description": "Discord channel for Claude Code \u2014 messaging bridge with built-in access control. Manage pairing, allowlists, and policy via /discord:access.",
|
||||
"verified": false,
|
||||
"type": "external",
|
||||
"installId": "discord@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "fakechat",
|
||||
"description": "Localhost iMessage-style web chat for Claude Code \u2014 test surface with file upload and edits. No tokens, no access control.",
|
||||
"verified": false,
|
||||
"type": "external",
|
||||
"installId": "fakechat@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "firebase",
|
||||
"description": "Google Firebase MCP integration. Manage Firestore databases, authentication, cloud functions, hosting, and storage. Build and manage your Firebase backend directly from your development workflow.",
|
||||
"verified": false,
|
||||
"type": "external",
|
||||
"installId": "firebase@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "github",
|
||||
"description": "Official GitHub MCP server for repository management. Create issues, manage pull requests, review code, search repositories, and interact with GitHub's full API directly from Claude Code.",
|
||||
"verified": false,
|
||||
"type": "external",
|
||||
"installId": "github@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "gitlab",
|
||||
"description": "GitLab DevOps platform integration. Manage repositories, merge requests, CI/CD pipelines, issues, and wikis. Full access to GitLab's comprehensive DevOps lifecycle tools.",
|
||||
"verified": false,
|
||||
"type": "external",
|
||||
"installId": "gitlab@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "greptile",
|
||||
"description": "AI code review agent for GitHub and GitLab. View and resolve Greptile's PR review comments directly from Claude Code.",
|
||||
"verified": false,
|
||||
"type": "external",
|
||||
"installId": "greptile@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "laravel-boost",
|
||||
"description": "Laravel development toolkit MCP server. Provides intelligent assistance for Laravel applications including Artisan commands, Eloquent queries, routing, migrations, and framework-specific code generation.",
|
||||
"verified": false,
|
||||
"type": "external",
|
||||
"installId": "laravel-boost@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "linear",
|
||||
"description": "Linear issue tracking integration. Create issues, manage projects, update statuses, search across workspaces, and streamline your software development workflow with Linear's modern issue tracker.",
|
||||
"verified": false,
|
||||
"type": "external",
|
||||
"installId": "linear@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "playwright",
|
||||
"description": "Browser automation and end-to-end testing MCP server by Microsoft. Enables Claude to interact with web pages, take screenshots, fill forms, click elements, and perform automated browser testing workflows.",
|
||||
"verified": false,
|
||||
"type": "external",
|
||||
"installId": "playwright@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "serena",
|
||||
"description": "Semantic code analysis MCP server providing intelligent code understanding, refactoring suggestions, and codebase navigation through language server protocol integration.",
|
||||
"verified": false,
|
||||
"type": "external",
|
||||
"installId": "serena@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "slack",
|
||||
"description": "Slack workspace integration. Search messages, access channels, read threads, and stay connected with your team's communications while coding. Find relevant discussions and context quickly.",
|
||||
"verified": false,
|
||||
"type": "external",
|
||||
"installId": "slack@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "supabase",
|
||||
"description": "Supabase MCP integration for database operations, authentication, storage, and real-time subscriptions. Manage your Supabase projects, run SQL queries, and interact with your backend directly.",
|
||||
"verified": false,
|
||||
"type": "external",
|
||||
"installId": "supabase@claude-plugins-official"
|
||||
},
|
||||
{
|
||||
"name": "telegram",
|
||||
"description": "Telegram channel for Claude Code \u2014 messaging bridge with built-in access control. Manage pairing, allowlists, and policy via /telegram:access.",
|
||||
"verified": false,
|
||||
"type": "external",
|
||||
"installId": "telegram@claude-plugins-official"
|
||||
}
|
||||
]
|
||||
289
src/top-skills.json
Normal file
289
src/top-skills.json
Normal file
@@ -0,0 +1,289 @@
|
||||
[
|
||||
{
|
||||
"id": "vercel-labs/skills/find-skills",
|
||||
"name": "find-skills",
|
||||
"installs": 654260,
|
||||
"source": "vercel-labs/skills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/vercel-labs/skills/main/skills/find-skills/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "vercel-labs/agent-skills/vercel-react-best-practices",
|
||||
"name": "vercel-react-best-practices",
|
||||
"installs": 234225,
|
||||
"source": "vercel-labs/agent-skills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/vercel-labs/agent-skills/main/skills/react-best-practices/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "vercel-labs/agent-skills/web-design-guidelines",
|
||||
"name": "web-design-guidelines",
|
||||
"installs": 187122,
|
||||
"source": "vercel-labs/agent-skills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/vercel-labs/agent-skills/main/skills/web-design-guidelines/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "anthropics/skills/frontend-design",
|
||||
"name": "frontend-design",
|
||||
"installs": 184608,
|
||||
"source": "anthropics/skills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/anthropics/skills/main/skills/frontend-design/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "vercel-labs/agent-browser/agent-browser",
|
||||
"name": "agent-browser",
|
||||
"installs": 119125,
|
||||
"source": "vercel-labs/agent-browser",
|
||||
"rawUrl": "https://raw.githubusercontent.com/vercel-labs/agent-browser/main/skills/agent-browser/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "anthropics/skills/skill-creator",
|
||||
"name": "skill-creator",
|
||||
"installs": 97605,
|
||||
"source": "anthropics/skills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/anthropics/skills/main/skills/skill-creator/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "nextlevelbuilder/ui-ux-pro-max-skill/ui-ux-pro-max",
|
||||
"name": "ui-ux-pro-max",
|
||||
"installs": 74564,
|
||||
"source": "nextlevelbuilder/ui-ux-pro-max-skill",
|
||||
"rawUrl": "https://raw.githubusercontent.com/nextlevelbuilder/ui-ux-pro-max-skill/main/.claude/skills/ui-ux-pro-max/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "microsoft/azure-skills/microsoft-foundry",
|
||||
"name": "microsoft-foundry",
|
||||
"installs": 74376,
|
||||
"source": "microsoft/azure-skills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/microsoft/azure-skills/main/skills/microsoft-foundry/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "obra/superpowers/brainstorming",
|
||||
"name": "brainstorming",
|
||||
"installs": 66697,
|
||||
"source": "obra/superpowers",
|
||||
"rawUrl": "https://raw.githubusercontent.com/obra/superpowers/main/skills/brainstorming/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "browser-use/browser-use/browser-use",
|
||||
"name": "browser-use",
|
||||
"installs": 52773,
|
||||
"source": "browser-use/browser-use",
|
||||
"rawUrl": "https://raw.githubusercontent.com/browser-use/browser-use/main/skills/browser-use/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "coreyhaines31/marketingskills/seo-audit",
|
||||
"name": "seo-audit",
|
||||
"installs": 50157,
|
||||
"source": "coreyhaines31/marketingskills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/coreyhaines31/marketingskills/main/skills/seo-audit/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "anthropics/skills/pdf",
|
||||
"name": "pdf",
|
||||
"installs": 45709,
|
||||
"source": "anthropics/skills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/anthropics/skills/main/skills/pdf/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "supabase/agent-skills/supabase-postgres-best-practices",
|
||||
"name": "supabase-postgres-best-practices",
|
||||
"installs": 43862,
|
||||
"source": "supabase/agent-skills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/supabase/agent-skills/main/skills/supabase-postgres-best-practices/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "coreyhaines31/marketingskills/copywriting",
|
||||
"name": "copywriting",
|
||||
"installs": 42743,
|
||||
"source": "coreyhaines31/marketingskills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/coreyhaines31/marketingskills/main/skills/copywriting/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "anthropics/skills/pptx",
|
||||
"name": "pptx",
|
||||
"installs": 41526,
|
||||
"source": "anthropics/skills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/anthropics/skills/main/skills/pptx/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "vercel-labs/next-skills/next-best-practices",
|
||||
"name": "next-best-practices",
|
||||
"installs": 40732,
|
||||
"source": "vercel-labs/next-skills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/vercel-labs/next-skills/main/skills/next-best-practices/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "squirrelscan/skills/audit-website",
|
||||
"name": "audit-website",
|
||||
"installs": 37654,
|
||||
"source": "squirrelscan/skills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/squirrelscan/skills/main/audit-website/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "obra/superpowers/systematic-debugging",
|
||||
"name": "systematic-debugging",
|
||||
"installs": 36470,
|
||||
"source": "obra/superpowers",
|
||||
"rawUrl": "https://raw.githubusercontent.com/obra/superpowers/main/skills/systematic-debugging/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "anthropics/skills/docx",
|
||||
"name": "docx",
|
||||
"installs": 35928,
|
||||
"source": "anthropics/skills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/anthropics/skills/main/skills/docx/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "obra/superpowers/writing-plans",
|
||||
"name": "writing-plans",
|
||||
"installs": 35010,
|
||||
"source": "obra/superpowers",
|
||||
"rawUrl": "https://raw.githubusercontent.com/obra/superpowers/main/skills/writing-plans/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "shadcn/ui/shadcn",
|
||||
"name": "shadcn",
|
||||
"installs": 33897,
|
||||
"source": "shadcn/ui",
|
||||
"rawUrl": "https://raw.githubusercontent.com/shadcn/ui/main/skills/shadcn/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "anthropics/skills/xlsx",
|
||||
"name": "xlsx",
|
||||
"installs": 32936,
|
||||
"source": "anthropics/skills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/anthropics/skills/main/skills/xlsx/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "obra/superpowers/using-superpowers",
|
||||
"name": "using-superpowers",
|
||||
"installs": 30937,
|
||||
"source": "obra/superpowers",
|
||||
"rawUrl": "https://raw.githubusercontent.com/obra/superpowers/main/skills/using-superpowers/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "coreyhaines31/marketingskills/marketing-psychology",
|
||||
"name": "marketing-psychology",
|
||||
"installs": 30917,
|
||||
"source": "coreyhaines31/marketingskills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/coreyhaines31/marketingskills/main/skills/marketing-psychology/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "obra/superpowers/test-driven-development",
|
||||
"name": "test-driven-development",
|
||||
"installs": 30410,
|
||||
"source": "obra/superpowers",
|
||||
"rawUrl": "https://raw.githubusercontent.com/obra/superpowers/main/skills/test-driven-development/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "anthropics/skills/webapp-testing",
|
||||
"name": "webapp-testing",
|
||||
"installs": 29748,
|
||||
"source": "anthropics/skills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/anthropics/skills/main/skills/webapp-testing/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "obra/superpowers/executing-plans",
|
||||
"name": "executing-plans",
|
||||
"installs": 28743,
|
||||
"source": "obra/superpowers",
|
||||
"rawUrl": "https://raw.githubusercontent.com/obra/superpowers/main/skills/executing-plans/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "obra/superpowers/requesting-code-review",
|
||||
"name": "requesting-code-review",
|
||||
"installs": 28421,
|
||||
"source": "obra/superpowers",
|
||||
"rawUrl": "https://raw.githubusercontent.com/obra/superpowers/main/skills/requesting-code-review/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "coreyhaines31/marketingskills/content-strategy",
|
||||
"name": "content-strategy",
|
||||
"installs": 27875,
|
||||
"source": "coreyhaines31/marketingskills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/coreyhaines31/marketingskills/main/skills/content-strategy/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "coreyhaines31/marketingskills/programmatic-seo",
|
||||
"name": "programmatic-seo",
|
||||
"installs": 27820,
|
||||
"source": "coreyhaines31/marketingskills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/coreyhaines31/marketingskills/main/skills/programmatic-seo/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "coreyhaines31/marketingskills/social-content",
|
||||
"name": "social-content",
|
||||
"installs": 26700,
|
||||
"source": "coreyhaines31/marketingskills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/coreyhaines31/marketingskills/main/skills/social-content/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "coreyhaines31/marketingskills/product-marketing-context",
|
||||
"name": "product-marketing-context",
|
||||
"installs": 25930,
|
||||
"source": "coreyhaines31/marketingskills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/coreyhaines31/marketingskills/main/skills/product-marketing-context/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "coreyhaines31/marketingskills/marketing-ideas",
|
||||
"name": "marketing-ideas",
|
||||
"installs": 25516,
|
||||
"source": "coreyhaines31/marketingskills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/coreyhaines31/marketingskills/main/skills/marketing-ideas/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "roin-orca/skills/simple",
|
||||
"name": "simple",
|
||||
"installs": 25467,
|
||||
"source": "roin-orca/skills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/roin-orca/skills/main/skills/simple/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "coreyhaines31/marketingskills/pricing-strategy",
|
||||
"name": "pricing-strategy",
|
||||
"installs": 25142,
|
||||
"source": "coreyhaines31/marketingskills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/coreyhaines31/marketingskills/main/skills/pricing-strategy/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "anthropics/skills/mcp-builder",
|
||||
"name": "mcp-builder",
|
||||
"installs": 24764,
|
||||
"source": "anthropics/skills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/anthropics/skills/main/skills/mcp-builder/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "obra/superpowers/subagent-driven-development",
|
||||
"name": "subagent-driven-development",
|
||||
"installs": 24432,
|
||||
"source": "obra/superpowers",
|
||||
"rawUrl": "https://raw.githubusercontent.com/obra/superpowers/main/skills/subagent-driven-development/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "coreyhaines31/marketingskills/copy-editing",
|
||||
"name": "copy-editing",
|
||||
"installs": 24073,
|
||||
"source": "coreyhaines31/marketingskills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/coreyhaines31/marketingskills/main/skills/copy-editing/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "pbakaus/impeccable/frontend-design",
|
||||
"name": "frontend-design",
|
||||
"installs": 23984,
|
||||
"source": "pbakaus/impeccable",
|
||||
"rawUrl": "https://raw.githubusercontent.com/pbakaus/impeccable/main/.claude/skills/frontend-design/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "pbakaus/impeccable/polish",
|
||||
"name": "polish",
|
||||
"installs": 23360,
|
||||
"source": "pbakaus/impeccable",
|
||||
"rawUrl": "https://raw.githubusercontent.com/pbakaus/impeccable/main/.claude/skills/polish/SKILL.md"
|
||||
},
|
||||
{
|
||||
"id": "google-labs-code/stitch-skills/design-md",
|
||||
"name": "design-md",
|
||||
"installs": 19272,
|
||||
"source": "google-labs-code/stitch-skills",
|
||||
"rawUrl": "https://raw.githubusercontent.com/google-labs-code/stitch-skills/main/skills/design-md/SKILL.md"
|
||||
}
|
||||
]
|
||||
1879
src/ui-styles.ts
1879
src/ui-styles.ts
File diff suppressed because it is too large
Load Diff
571
src/ui.ts
571
src/ui.ts
@@ -1,12 +1,19 @@
|
||||
import getScript from './script';
|
||||
import styles from './ui-styles'
|
||||
import recommendedModels from './recommended-models.json'
|
||||
import topMcpServers from './top-mcp-servers.json'
|
||||
import topSkills from './top-skills.json'
|
||||
import topPlugins from './top-plugins.json'
|
||||
import getSkillsHtml from './skills-ui'
|
||||
import getPluginsHtml from './plugins-ui'
|
||||
|
||||
|
||||
const getHtml = (isTelemetryEnabled: boolean) => `<!DOCTYPE html>
|
||||
const getHtml = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'https://ccc.api.opencredits.ai', opencreditsWebUrl: string = 'https://ccc.opencredits.ai', opencreditsPublishableKey: string = 'oc_pk_c43da4f9a9484ae484ad29bc97cc354f', editorName: string = 'unknown', extensionVersion: string = 'unknown') => `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src * 'unsafe-inline' 'unsafe-eval' data: blob:; frame-src *;">
|
||||
<title>Claude Code Chat</title>
|
||||
${styles}
|
||||
</head>
|
||||
@@ -57,33 +64,48 @@ const getHtml = (isTelemetryEnabled: boolean) => `<!DOCTYPE html>
|
||||
</div>
|
||||
|
||||
<div class="input-container" id="inputContainer">
|
||||
<div class="input-modes">
|
||||
<div class="mode-toggle">
|
||||
<span onclick="togglePlanMode()">Plan First</span>
|
||||
<div class="mode-switch" id="planModeSwitch" onclick="togglePlanMode()"></div>
|
||||
</div>
|
||||
<div class="mode-toggle">
|
||||
<span id="thinkingModeLabel" onclick="toggleThinkingMode()">Thinking Mode</span>
|
||||
<div class="mode-switch" id="thinkingModeSwitch" onclick="toggleThinkingMode()"></div>
|
||||
<div class="model-selector-row">
|
||||
<button class="model-selector-main" id="modelDropdownBtn" onclick="showModelSelector()" title="Select model">
|
||||
<span id="modelDropdownText">Opus</span>
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor"><path d="M1 2.5l3 3 3-3"></path></svg>
|
||||
</button>
|
||||
<button class="model-selector-main" id="modelSelector" onclick="showModelSelector()" title="Select model" style="display: none;">
|
||||
<span class="model-selector-new" id="modelSelectorBadge">NEW</span>
|
||||
<span id="modelSelectorText">Try other models</span>
|
||||
</button>
|
||||
<div class="model-quick-select" id="modelQuickSelect">
|
||||
</div>
|
||||
<button class="model-more-btn" id="modelMoreBtn" onclick="showModelSelector()" style="display: none;">+</button>
|
||||
</div>
|
||||
<div class="textarea-container">
|
||||
<div class="textarea-wrapper">
|
||||
<div class="image-preview-container" id="imagePreviewContainer" style="display: none;"></div>
|
||||
<textarea class="input-field" id="messageInput" placeholder="Type your message to Claude Code..." rows="1"></textarea>
|
||||
<div class="input-controls">
|
||||
<div class="left-controls">
|
||||
<button class="model-selector" id="modelSelector" onclick="showModelSelector()" title="Select model">
|
||||
<span id="selectedModel">Opus</span>
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor">
|
||||
<path d="M1 2.5l3 3 3-3"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="tools-btn" onclick="showMCPModal()" title="Configure MCP servers">
|
||||
MCP
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor">
|
||||
<path d="M1 2.5l3 3 3-3"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="connect-dropdown-wrapper">
|
||||
<button class="input-dropdown-btn" id="connectBtn" onclick="toggleConnectMenu()">
|
||||
<span>Add</span>
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor"><path d="M1 2.5l3 3 3-3"></path></svg>
|
||||
</button>
|
||||
<div class="connect-menu" id="connectMenu" style="display: none;">
|
||||
<div class="connect-menu-header">Add</div>
|
||||
<button class="connect-menu-item" onclick="hideConnectMenu(); showPluginsModal();">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
|
||||
<span>Plugins</span>
|
||||
</button>
|
||||
<button class="connect-menu-item" onclick="hideConnectMenu(); showSkillsModal();">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
||||
<span>Skills</span>
|
||||
</button>
|
||||
<button class="connect-menu-item" onclick="hideConnectMenu(); showMCPModal();">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1" fill="currentColor"/><circle cx="6" cy="18" r="1" fill="currentColor"/></svg>
|
||||
<span>MCP Servers</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="input-toggle-btn" id="planToggleBtn" onclick="cyclePlanMode()">Plan</button>
|
||||
<button class="input-toggle-btn" id="thinkToggleBtn" onclick="toggleThinkingMode()">Ultrathink</button>
|
||||
</div>
|
||||
<div class="right-controls">
|
||||
<button class="slash-btn" onclick="showSlashCommandsModal()" title="Slash commands">/</button>
|
||||
@@ -102,21 +124,19 @@ const getHtml = (isTelemetryEnabled: boolean) => `<!DOCTYPE html>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="send-btn" id="sendBtn" onclick="sendMessage()">
|
||||
<div>
|
||||
<span>Send </span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="11"
|
||||
height="11"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M20 4v9a4 4 0 0 1-4 4H6.914l2.5 2.5L8 20.914L3.086 16L8 11.086L9.414 12.5l-2.5 2.5H16a2 2 0 0 0 2-2V4z"
|
||||
></path>
|
||||
<div>
|
||||
<span>Send </span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="11" height="11">
|
||||
<path fill="currentColor" d="M20 4v9a4 4 0 0 1-4 4H6.914l2.5 2.5L8 20.914L3.086 16L8 11.086L9.414 12.5l-2.5 2.5H16a2 2 0 0 0 2-2V4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<button class="stop-inline-btn" id="stopInlineBtn" onclick="stopRequest()" style="display: none;">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 6h12v12H6z"/>
|
||||
</svg>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,11 +147,9 @@ const getHtml = (isTelemetryEnabled: boolean) => `<!DOCTYPE html>
|
||||
<div class="status ready" id="status">
|
||||
<div class="status-indicator"></div>
|
||||
<div class="status-text" id="statusText">Initializing...</div>
|
||||
<button class="btn stop" id="stopBtn" onclick="stopRequest()" style="display: none;">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 6h12v12H6z"/>
|
||||
</svg>
|
||||
Stop
|
||||
<button class="support-btn" onclick="showSupportModal()" title="Send feedback">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
Support
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -163,54 +181,15 @@ const getHtml = (isTelemetryEnabled: boolean) => `<!DOCTYPE html>
|
||||
<div class="mcp-servers-list" id="mcpServersList">
|
||||
<!-- MCP servers will be loaded here -->
|
||||
</div>
|
||||
<div class="mcp-add-server">
|
||||
<button class="btn outlined" onclick="showAddServerForm()" id="addServerBtn">+ Add MCP Server</button>
|
||||
</div>
|
||||
<div class="mcp-popular-servers" id="popularServers">
|
||||
<h4>Popular MCP Servers</h4>
|
||||
<div class="popular-servers-grid">
|
||||
<div class="popular-server-item" onclick="addPopularServer('context7', { type: 'http', url: 'https://context7.liam.sh/mcp' })">
|
||||
<div class="popular-server-icon">📚</div>
|
||||
<div class="popular-server-info">
|
||||
<div class="popular-server-name">Context7</div>
|
||||
<div class="popular-server-desc">Up-to-date Code Docs For Any Prompt</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="popular-server-item" onclick="addPopularServer('sequential-thinking', { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-sequential-thinking'] })">
|
||||
<div class="popular-server-icon">🔗</div>
|
||||
<div class="popular-server-info">
|
||||
<div class="popular-server-name">Sequential Thinking</div>
|
||||
<div class="popular-server-desc">Step-by-step reasoning capabilities</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="popular-server-item" onclick="addPopularServer('memory', { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-memory'] })">
|
||||
<div class="popular-server-icon">🧠</div>
|
||||
<div class="popular-server-info">
|
||||
<div class="popular-server-name">Memory</div>
|
||||
<div class="popular-server-desc">Knowledge graph storage</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="popular-server-item" onclick="addPopularServer('puppeteer', { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-puppeteer'] })">
|
||||
<div class="popular-server-icon">🎭</div>
|
||||
<div class="popular-server-info">
|
||||
<div class="popular-server-name">Puppeteer</div>
|
||||
<div class="popular-server-desc">Browser automation</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="popular-server-item" onclick="addPopularServer('fetch', { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-fetch'] })">
|
||||
<div class="popular-server-icon">🌐</div>
|
||||
<div class="popular-server-info">
|
||||
<div class="popular-server-name">Fetch</div>
|
||||
<div class="popular-server-desc">HTTP requests & web scraping</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="popular-server-item" onclick="addPopularServer('filesystem', { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem'] })">
|
||||
<div class="popular-server-icon">📁</div>
|
||||
<div class="popular-server-info">
|
||||
<div class="popular-server-name">Filesystem</div>
|
||||
<div class="popular-server-desc">File operations & management</div>
|
||||
</div>
|
||||
</div>
|
||||
<h4>Search MCP Servers</h4>
|
||||
<div class="marketplace-search">
|
||||
<input type="text" id="marketplaceSearch" placeholder="Search MCP servers..." oninput="filterMarketplace(this.value)" />
|
||||
</div>
|
||||
<div class="marketplace-grid" id="marketplaceGrid">
|
||||
</div>
|
||||
<div class="marketplace-load-more" id="marketplaceLoadMore" style="display: none;">
|
||||
<button class="btn outlined" onclick="loadMoreMarketplace()">Load more</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mcp-add-form" id="addServerForm" style="display: none;">
|
||||
@@ -218,6 +197,13 @@ const getHtml = (isTelemetryEnabled: boolean) => `<!DOCTYPE html>
|
||||
<label for="serverName">Server Name:</label>
|
||||
<input type="text" id="serverName" placeholder="my-server" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="serverScope">Install to:</label>
|
||||
<select id="serverScope">
|
||||
<option value="project">Project (.mcp.json)</option>
|
||||
<option value="global">Global (~/.claude.json)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="serverType">Server Type:</label>
|
||||
<select id="serverType" onchange="updateServerForm()">
|
||||
@@ -252,17 +238,56 @@ const getHtml = (isTelemetryEnabled: boolean) => `<!DOCTYPE html>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tools-list" id="mcpMarketplaceView" style="display: none;">
|
||||
<div class="marketplace-search">
|
||||
<input type="text" id="marketplaceSearch" placeholder="Search MCP servers..." oninput="filterMarketplace(this.value)" />
|
||||
</div>
|
||||
<div class="marketplace-grid" id="marketplaceGrid">
|
||||
<div class="marketplace-loading">Loading servers...</div>
|
||||
</div>
|
||||
<div class="marketplace-load-more" id="marketplaceLoadMore" style="display: none;">
|
||||
<button class="btn outlined" onclick="loadMoreMarketplace()">Load more</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Support modal -->
|
||||
<div id="supportModal" class="tools-modal" style="display: none;">
|
||||
<div class="tools-modal-content" style="max-width: 420px;">
|
||||
<div class="tools-modal-header">
|
||||
<h3>Send Feedback</h3>
|
||||
<button class="tools-close-btn" onclick="hideSupportModal()">✕</button>
|
||||
</div>
|
||||
<div style="padding: 16px; display: flex; flex-direction: column; gap: 12px;">
|
||||
<div>
|
||||
<label style="font-size: 12px; color: var(--vscode-descriptionForeground); display: block; margin-bottom: 4px;">Type</label>
|
||||
<select id="supportType" style="width: 100%; padding: 6px 8px; background: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border); border-radius: 4px; font-size: 13px;">
|
||||
<option value="bug">Bug Report</option>
|
||||
<option value="feature">Feature Request</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size: 12px; color: var(--vscode-descriptionForeground); display: block; margin-bottom: 4px;">Email (optional)</label>
|
||||
<input type="email" id="supportEmail" placeholder="your@email.com" style="width: 100%; padding: 6px 8px; background: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border); border-radius: 4px; font-size: 13px; box-sizing: border-box;" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size: 12px; color: var(--vscode-descriptionForeground); display: block; margin-bottom: 4px;">Message</label>
|
||||
<textarea id="supportMessage" rows="5" placeholder="Describe the issue or suggestion..." style="width: 100%; padding: 6px 8px; background: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border); border-radius: 4px; font-size: 13px; resize: vertical; box-sizing: border-box;"></textarea>
|
||||
</div>
|
||||
<button id="supportSubmitBtn" onclick="submitSupport()" style="padding: 8px 16px; background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; border-radius: 4px; cursor: pointer; font-size: 13px;">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings modal -->
|
||||
<div id="settingsModal" class="tools-modal" style="display: none;">
|
||||
<div class="tools-modal-content">
|
||||
<div class="tools-modal-content" style="max-height: 600px;">
|
||||
<div class="tools-modal-header">
|
||||
<span>Claude Code Chat Settings</span>
|
||||
<button class="tools-close-btn" onclick="hideSettingsModal()">✕</button>
|
||||
</div>
|
||||
<div class="tools-list">
|
||||
<div class="tools-list" style="max-height: none;">
|
||||
<h3 style="margin-top: 0; margin-bottom: 16px; font-size: 14px; font-weight: 600;">WSL Configuration</h3>
|
||||
<div>
|
||||
<p style="font-size: 11px; color: var(--vscode-descriptionForeground); margin: 0;">
|
||||
@@ -282,14 +307,6 @@ const getHtml = (isTelemetryEnabled: boolean) => `<!DOCTYPE html>
|
||||
<input type="text" id="wsl-distro" class="file-search-input" style="width: 100%;" placeholder="Ubuntu" onchange="updateSettings()">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 12px;">
|
||||
<label style="display: block; margin-bottom: 4px; font-size: 12px; color: var(--vscode-descriptionForeground);">Node.js Path in WSL</label>
|
||||
<input type="text" id="wsl-node-path" class="file-search-input" style="width: 100%;" placeholder="/usr/bin/node" onchange="updateSettings()">
|
||||
<p style="font-size: 11px; color: var(--vscode-descriptionForeground); margin: 4px 0 0 0;">
|
||||
Find your node installation path in WSL by running: <code style="background: var(--vscode-textCodeBlock-background); padding: 2px 4px; border-radius: 3px;">which node</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 12px;">
|
||||
<label style="display: block; margin-bottom: 4px; font-size: 12px; color: var(--vscode-descriptionForeground);">Claude Path in WSL</label>
|
||||
<input type="text" id="wsl-claude-path" class="file-search-input" style="width: 100%;" placeholder="/usr/local/bin/claude" onchange="updateSettings()">
|
||||
@@ -297,6 +314,14 @@ const getHtml = (isTelemetryEnabled: boolean) => `<!DOCTYPE html>
|
||||
Find your claude installation path in WSL by running: <code style="background: var(--vscode-textCodeBlock-background); padding: 2px 4px; border-radius: 3px;">which claude</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 12px;">
|
||||
<label style="display: block; margin-bottom: 4px; font-size: 12px; color: var(--vscode-descriptionForeground);">Node.js Path in WSL (Optional)</label>
|
||||
<input type="text" id="wsl-node-path" class="file-search-input" style="width: 100%;" placeholder="/usr/bin/node" onchange="updateSettings()">
|
||||
<p style="font-size: 11px; color: var(--vscode-descriptionForeground); margin: 4px 0 0 0;">
|
||||
Optional. Only needed if you previously installed Claude via npm. Recent Claude installs ship as a native executable and don't need Node. Set it by running: <code style="background: var(--vscode-textCodeBlock-background); padding: 2px 4px; border-radius: 3px;">which node</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -347,54 +372,186 @@ const getHtml = (isTelemetryEnabled: boolean) => `<!DOCTYPE html>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<h3 style="margin-top: 24px; margin-bottom: 16px; font-size: 14px; font-weight: 600;">Customize Claude Command</h3>
|
||||
<div>
|
||||
<p style="font-size: 11px; color: var(--vscode-descriptionForeground); margin: 0 0 12px 0;">
|
||||
Customize the Claude Code executable and environment.
|
||||
</p>
|
||||
<div id="opencreditsPromo" style="margin-bottom: 16px; padding: 14px 16px; border-radius: 8px; border: 1px solid var(--vscode-panel-border); background: rgba(139, 92, 246, 0.05);"></div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<div style="margin-bottom: 16px;">
|
||||
<label style="display: block; margin-bottom: 4px; font-size: 12px; color: var(--vscode-descriptionForeground);">Executable Path</label>
|
||||
<input type="text" id="executable-path" class="file-search-input" style="width: 100%;" placeholder="claude (default)" onchange="updateSettings()">
|
||||
<p style="font-size: 11px; color: var(--vscode-descriptionForeground); margin: 4px 0 0 0;">
|
||||
Custom path to the Claude Code executable. Leave empty to use the default <code style="background: var(--vscode-textCodeBlock-background); padding: 2px 4px; border-radius: 3px;">claude</code> command.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
|
||||
<label id="envsLabel" style="font-size: 12px; color: var(--vscode-descriptionForeground);">Environment Variables</label>
|
||||
<button id="envsToggleBtn" style="display: none; font-size: 10px; padding: 2px 10px; border-radius: 4px; border: 1px solid var(--vscode-panel-border); background: none; color: var(--vscode-descriptionForeground); cursor: pointer;"></button>
|
||||
</div>
|
||||
<div id="env-variables-list" class="env-variables-list"></div>
|
||||
<button class="permissions-show-add-btn" style="margin-top: 8px;" onclick="addEnvVariable()">+ Add Variable</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-item" style="margin-top: 16px; display: none;">
|
||||
<input type="checkbox" id="use-router" onchange="updateSettings()">
|
||||
<label for="use-router">Enable OpenAI → Anthropic Router</label>
|
||||
</div>
|
||||
<p style="display: none; font-size: 11px; color: var(--vscode-descriptionForeground); margin: 4px 0 0 24px;">
|
||||
Enable this if your API provider uses OpenAI-compatible format. The router will convert requests/responses locally.
|
||||
</p>
|
||||
|
||||
<div id="providerExclusionSection" class="tool-item" style="margin-top: 16px; display: none;">
|
||||
<input type="checkbox" id="us-eu-providers-only" onchange="applyProviderExclusion()">
|
||||
<label for="us-eu-providers-only">Only use US & EU providers</label>
|
||||
</div>
|
||||
<p id="providerExclusionHint" style="display: none; font-size: 11px; color: var(--vscode-descriptionForeground); margin: 4px 0 0 24px;">
|
||||
When enabled, requests are routed only through US and EU-based infrastructure providers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model selector modal -->
|
||||
<div id="modelModal" class="tools-modal" style="display: none;">
|
||||
<div class="tools-modal-content" style="width: 400px;">
|
||||
<div class="tools-modal-content model-modal-content">
|
||||
<div class="tools-modal-header">
|
||||
<span>Enforce Model</span>
|
||||
<span>Select Model</span>
|
||||
<button class="tools-close-btn" onclick="hideModelModal()">✕</button>
|
||||
</div>
|
||||
<div class="model-explanatory-text">
|
||||
This overrides your default model setting for this conversation only.
|
||||
|
||||
<!-- Claude Code section -->
|
||||
<div id="claudeCodeSection" class="model-section">
|
||||
<div class="model-section-header">
|
||||
<span class="model-section-title">CLAUDE CODE STANDARD MODELS</span>
|
||||
</div>
|
||||
<div class="claude-cards-container" id="claudeModelCards">
|
||||
<div class="claude-card" data-model="opus" onclick="selectModel('opus')">
|
||||
<div class="claude-card-name">Opus</div>
|
||||
<div class="claude-card-desc">Most powerful, best for complex tasks</div>
|
||||
</div>
|
||||
<div class="claude-card" data-model="sonnet" onclick="selectModel('sonnet')">
|
||||
<div class="claude-card-name">Sonnet</div>
|
||||
<div class="claude-card-desc">Balanced performance and speed</div>
|
||||
</div>
|
||||
<div class="claude-card" data-model="default" onclick="selectModel('default')">
|
||||
<div class="claude-card-name">Default</div>
|
||||
<div class="claude-card-desc">Let Claude Code choose the best model</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tools-list">
|
||||
<div class="tool-item" onclick="selectModel('opus')">
|
||||
<input type="radio" name="model" id="model-opus" value="opus" checked>
|
||||
<label for="model-opus">
|
||||
<div class="model-title">Opus - Most capable model</div>
|
||||
<div class="model-description">
|
||||
Best for complex tasks and highest quality output
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- Divider (only shown when both sections visible) -->
|
||||
<div id="modelSectionDivider" class="model-section-divider" style="display: none;"></div>
|
||||
|
||||
<!-- Other models section -->
|
||||
<div id="opencreditsModelsSection" class="model-section opencredits-section" style="display: none;">
|
||||
<div class="model-section-header">
|
||||
<span class="model-section-title">TRY OTHER MODELS <span class="new-badge">NEW</span><span class="beta-badge" data-tooltip="This feature is in beta. Experience may vary across models.">BETA</span></span>
|
||||
</div>
|
||||
<div class="tool-item" onclick="selectModel('sonnet')">
|
||||
<input type="radio" name="model" id="model-sonnet" value="sonnet">
|
||||
<label for="model-sonnet">
|
||||
<div class="model-title">Sonnet - Balanced model</div>
|
||||
<div class="model-description">
|
||||
Good balance of speed and capability
|
||||
</div>
|
||||
</label>
|
||||
<div id="opencreditsComparisonHeader"></div>
|
||||
<div class="model-cards-container" id="opencreditsModelCards">
|
||||
<!-- Cards populated by JavaScript -->
|
||||
</div>
|
||||
<div class="tool-item" onclick="selectModel('default')">
|
||||
<input type="radio" name="model" id="model-default" value="default">
|
||||
<label for="model-default" class="default-model-layout">
|
||||
<div class="model-option-content">
|
||||
<div class="model-title">Default - User configured</div>
|
||||
<div class="model-description">
|
||||
Uses the model configured in your settings
|
||||
</div>
|
||||
</div>
|
||||
<button class="secondary-button configure-button" onclick="event.stopPropagation(); openModelTerminal();">
|
||||
Configure
|
||||
</button>
|
||||
</label>
|
||||
<div style="margin: 14px 0 0 0; padding: 8px 10px; border-radius: 6px; background: var(--vscode-textBlockQuote-background, rgba(127,127,127,0.1)); display: flex; gap: 6px; align-items: flex-start;">
|
||||
<span style="font-size: 10px; line-height: 1.4; flex-shrink: 0; opacity: 0.4;">ⓘ</span>
|
||||
<span style="font-size: 10px; color: var(--vscode-descriptionForeground); line-height: 1.4;">Savings are compared to using Claude Opus directly with Anthropic API. Models can be configured to use only US & EU providers.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- All Models Browser modal -->
|
||||
<div id="allModelsModal" class="tools-modal" style="display: none;">
|
||||
<div class="tools-modal-content" style="width: 500px; max-width: 95vw; max-height: 80vh;">
|
||||
<div class="tools-modal-header">
|
||||
<span>Browse All Models</span>
|
||||
<button class="tools-close-btn" onclick="hideAllModelsModal()">✕</button>
|
||||
</div>
|
||||
<div class="all-models-search">
|
||||
<input type="text" id="allModelsSearch" placeholder="Search models..." oninput="filterAllModels()">
|
||||
</div>
|
||||
<div class="all-models-list" id="allModelsList">
|
||||
<!-- Models populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Settings modal (OpenCredits) -->
|
||||
<div id="advancedModal" class="tools-modal" style="display: none;">
|
||||
<div class="tools-modal-content" style="width: 440px; max-width: 90vw; max-height: 80vh; overflow-y: auto; overflow-x: hidden;">
|
||||
<div class="tools-modal-header">
|
||||
<span>Advanced Settings</span>
|
||||
<button class="tools-close-btn" onclick="hideAdvancedModal()">✕</button>
|
||||
</div>
|
||||
<div style="padding: 16px;">
|
||||
<p style="font-size: 12px; color: var(--vscode-descriptionForeground); margin-bottom: 16px;">
|
||||
Override the default models used when you select Opus, Sonnet, or Haiku.
|
||||
</p>
|
||||
<div class="custom-provider-field">
|
||||
<label>Sonnet Model</label>
|
||||
<div class="model-combo" id="comboSonnet">
|
||||
<input type="text" class="model-combo-input" placeholder="Default — click to search models" autocomplete="off">
|
||||
<div class="model-combo-dropdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="custom-provider-field">
|
||||
<label>Opus Model</label>
|
||||
<div class="model-combo" id="comboOpus">
|
||||
<input type="text" class="model-combo-input" placeholder="Default — click to search models" autocomplete="off">
|
||||
<div class="model-combo-dropdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="custom-provider-field">
|
||||
<label>Haiku Model</label>
|
||||
<div class="model-combo" id="comboHaiku">
|
||||
<input type="text" class="model-combo-input" placeholder="Default — click to search models" autocomplete="off">
|
||||
<div class="model-combo-dropdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="install-btn" style="width: 100%; margin-top: 16px;" onclick="saveAdvancedSettings()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Provider modal -->
|
||||
<div id="customProviderModal" class="tools-modal" style="display: none;">
|
||||
<div class="tools-modal-content" style="width: 440px;">
|
||||
<div class="tools-modal-header">
|
||||
<span>Custom Provider</span>
|
||||
<button class="tools-close-btn" onclick="hideCustomProviderModal()">✕</button>
|
||||
</div>
|
||||
<div style="padding: 16px;">
|
||||
<p style="font-size: 12px; color: var(--vscode-descriptionForeground); margin-bottom: 16px;">
|
||||
Connect to any OpenAI-compatible or Anthropic-compatible API endpoint.
|
||||
</p>
|
||||
<div class="custom-provider-field">
|
||||
<label>Base URL</label>
|
||||
<input type="text" id="customProviderBaseUrl" placeholder="https://api.example.com">
|
||||
</div>
|
||||
<div class="custom-provider-field">
|
||||
<label>Auth Token</label>
|
||||
<input type="password" id="customProviderAuthToken" placeholder="sk-...">
|
||||
</div>
|
||||
<div class="custom-provider-field">
|
||||
<label>Sonnet Model <span style="opacity:0.5">(optional)</span></label>
|
||||
<input type="text" id="customProviderSonnet" placeholder="claude-sonnet-4-20250514">
|
||||
</div>
|
||||
<div class="custom-provider-field">
|
||||
<label>Opus Model <span style="opacity:0.5">(optional)</span></label>
|
||||
<input type="text" id="customProviderOpus" placeholder="claude-opus-4-20250514">
|
||||
</div>
|
||||
<div class="custom-provider-field">
|
||||
<label>Haiku Model <span style="opacity:0.5">(optional)</span></label>
|
||||
<input type="text" id="customProviderHaiku" placeholder="claude-haiku-4-20250514">
|
||||
</div>
|
||||
<button class="install-btn" style="width: 100%; margin-top: 16px;" onclick="saveCustomProvider()">Save & Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -426,6 +583,15 @@ const getHtml = (isTelemetryEnabled: boolean) => `<!DOCTYPE html>
|
||||
Install Now
|
||||
</button>
|
||||
|
||||
<div id="installRetryOptions" style="display: none; margin-top: 8px;">
|
||||
<button class="install-link" id="installRetryNpmBtn" onclick="startInstallationWithSudo()" style="background: none; border: none; color: var(--vscode-textLink-foreground); cursor: pointer; text-decoration: underline; padding: 4px;">
|
||||
Didn't work? Try with npm
|
||||
</button>
|
||||
<label id="installSudoLabel" style="display: none; margin-left: 10px; font-size: 11px; color: var(--vscode-descriptionForeground); cursor: pointer;">
|
||||
<input type="checkbox" id="installUseSudo" style="vertical-align: middle;"> Use sudo
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<a href="https://docs.anthropic.com/en/docs/claude-code" target="_blank" class="install-link">
|
||||
View documentation
|
||||
</a>
|
||||
@@ -443,9 +609,134 @@ const getHtml = (isTelemetryEnabled: boolean) => `<!DOCTYPE html>
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="install-success-text">Installation Complete</p>
|
||||
<p class="install-success-hint">Send a message to get started</p>
|
||||
<p class="install-success-text">Installed!</p>
|
||||
<p class="install-success-hint">How would you like to use Claude Code?</p>
|
||||
|
||||
<div class="install-options">
|
||||
<button class="install-option" onclick="loginWithPlan()">
|
||||
<span class="install-option-title">I have a plan</span>
|
||||
<span class="install-option-desc">Login with Anthropic · Pro, Max, or API key</span>
|
||||
</button>
|
||||
<button class="install-option install-option-secondary" id="installOpenCreditsOption" onclick="showFundsSelection()">
|
||||
<span class="install-option-title">Just try it</span>
|
||||
<span class="install-option-desc">No account needed · Pay as you go with OpenCredits</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="install-funds" id="installFunds" style="display: none;">
|
||||
<p class="install-funds-title">Add funds to get started</p>
|
||||
<p class="install-funds-hint">Pay as you go - no subscription required</p>
|
||||
|
||||
<div class="install-amounts">
|
||||
<button class="install-amount" onclick="selectFundsAmount(5)">$5</button>
|
||||
<button class="install-amount" onclick="selectFundsAmount(10)">$10</button>
|
||||
<button class="install-amount" onclick="selectFundsAmount(25)">$25</button>
|
||||
<button class="install-amount" onclick="selectFundsAmount(50)">$50</button>
|
||||
<button class="install-amount" onclick="selectFundsAmount(100)">$100</button>
|
||||
</div>
|
||||
|
||||
<div class="install-custom-amount">
|
||||
<span class="install-custom-currency">$</span>
|
||||
<input type="number" id="customAmountInput" class="install-custom-input" placeholder="Other" min="1" max="500" />
|
||||
<button class="install-custom-btn" onclick="selectCustomAmount()">Add</button>
|
||||
</div>
|
||||
|
||||
<div class="install-powered-by">
|
||||
Powered by <a href="${opencreditsWebUrl}" target="_blank">OpenCredits</a>
|
||||
</div>
|
||||
<p style="font-size: 10px; color: var(--vscode-descriptionForeground); margin: 8px 0 0; opacity: 0.7;">By continuing, you agree to OpenCredits' <a href="#" onclick="event.preventDefault(); vscode.postMessage({ type: 'openExternalUrl', url: '${opencreditsWebUrl}/legal/terms-of-service' });" style="color: var(--vscode-textLink-foreground);">Terms of Service</a> and <a href="#" onclick="event.preventDefault(); vscode.postMessage({ type: 'openExternalUrl', url: '${opencreditsWebUrl}/legal/privacy-policy' });" style="color: var(--vscode-textLink-foreground);">Privacy Policy</a>.</p>
|
||||
|
||||
<button class="install-back-btn" onclick="showInstallOptions()">
|
||||
← Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="install-checkout" id="installCheckout" style="display: none;">
|
||||
<div id="checkoutPreparing" style="text-align: center;">
|
||||
<div class="install-spinner" style="margin: 0 auto 16px;"></div>
|
||||
<p class="install-funds-title">Preparing checkout...</p>
|
||||
<p class="install-funds-hint">Please wait while we set up your payment</p>
|
||||
</div>
|
||||
|
||||
<div id="checkoutReady" style="display: none; text-align: center;">
|
||||
<p class="install-funds-title">Checkout opened in your browser</p>
|
||||
<p class="install-funds-hint">Complete your payment, then come back here.</p>
|
||||
<div id="checkoutUrlBox" style="display: flex; align-items: center; gap: 6px; margin: 12px 0; padding: 8px 12px; background: var(--vscode-textBlockQuote-background, rgba(255,255,255,0.05)); border-radius: 6px; border: 1px solid var(--vscode-panel-border); overflow: hidden; min-width: 0; max-width: 100%;">
|
||||
<span id="checkoutUrlDisplay" style="flex: 1; min-width: 0; font-size: 11px; color: var(--vscode-descriptionForeground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block;"></span>
|
||||
<button id="checkoutUrlCopyBtn" title="Copy URL" style="flex-shrink: 0; background: none; border: none; color: var(--vscode-foreground); cursor: pointer; padding: 2px; opacity: 0.7;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button id="checkoutOpenBtn" class="install-btn" style="margin-top: 8px;">Open Checkout Again</button>
|
||||
</div>
|
||||
|
||||
<div id="checkoutComplete" style="display: none; text-align: center;">
|
||||
<div class="install-success-icon" style="margin: 0 auto 16px;">
|
||||
<svg class="install-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="install-funds-title">Payment successful!</p>
|
||||
<p class="install-funds-hint">Your account has been funded.</p>
|
||||
<button class="install-btn" style="margin-top: 12px;" onclick="hideInstallModal()">Close</button>
|
||||
</div>
|
||||
|
||||
<div id="checkoutError" style="display: none; text-align: center;">
|
||||
<div style="width: 40px; height: 40px; margin: 0 auto 12px; border-radius: 50%; background: rgba(239, 68, 68, 0.15); display: flex; align-items: center; justify-content: center;">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="install-funds-title">Something went wrong</p>
|
||||
<p class="install-funds-hint" id="checkoutErrorMsg">Could not complete checkout. Please try again.</p>
|
||||
<button id="checkoutRetryBtn" class="install-btn" style="margin-top: 12px;">Try Again</button>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 10px; color: var(--vscode-descriptionForeground); margin: 16px 0 0; opacity: 0.7;">By continuing, you agree to OpenCredits' <a href="#" onclick="event.preventDefault(); vscode.postMessage({ type: 'openExternalUrl', url: 'https://opencredits.ai/terms' });" style="color: var(--vscode-textLink-foreground);">Terms of Service</a> and <a href="#" onclick="event.preventDefault(); vscode.postMessage({ type: 'openExternalUrl', url: 'https://opencredits.ai/privacy' });" style="color: var(--vscode-textLink-foreground);">Privacy Policy</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Provider choice modal -->
|
||||
<div id="providerChoiceModal" class="tools-modal" style="display: none;">
|
||||
<div class="tools-modal-content" style="width: 300px;">
|
||||
<div class="tools-modal-header">
|
||||
<span id="providerChoiceTitle">Use model via</span>
|
||||
<button class="tools-close-btn" onclick="document.getElementById('providerChoiceModal').style.display='none'">✕</button>
|
||||
</div>
|
||||
<div style="padding: 16px; display: flex; flex-direction: column; gap: 10px;">
|
||||
<button id="providerChoiceOpenCredits" style="padding: 12px 16px; border-radius: 8px; border: 1px solid var(--vscode-panel-border); background: var(--vscode-editor-background); color: var(--vscode-foreground); cursor: pointer; text-align: left;">
|
||||
<div style="font-weight: 600; font-size: 13px;">OpenCredits</div>
|
||||
<div style="font-size: 11px; color: var(--vscode-descriptionForeground); margin-top: 2px;">Pay as you go with your OpenCredits balance</div>
|
||||
</button>
|
||||
<button id="providerChoiceAnthropic" style="padding: 12px 16px; border-radius: 8px; border: 1px solid var(--vscode-panel-border); background: var(--vscode-editor-background); color: var(--vscode-foreground); cursor: pointer; text-align: left;">
|
||||
<div style="font-weight: 600; font-size: 13px;">Anthropic</div>
|
||||
<div style="font-size: 11px; color: var(--vscode-descriptionForeground); margin-top: 2px;">Use your Anthropic API key or subscription</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- External URL opened modal -->
|
||||
<div id="externalUrlModal" class="tools-modal" style="display: none;">
|
||||
<div class="tools-modal-content" style="width: 380px;">
|
||||
<div class="tools-modal-header">
|
||||
<span>Opening Browser</span>
|
||||
<button class="tools-close-btn" onclick="document.getElementById('externalUrlModal').style.display='none'">✕</button>
|
||||
</div>
|
||||
<div style="padding: 24px; text-align: center;">
|
||||
<p style="margin: 0 0 16px; font-size: 13px; color: var(--vscode-foreground);">A page should have opened in your browser.</p>
|
||||
<div style="display: flex; align-items: center; gap: 6px; margin: 0 0 20px; padding: 8px 12px; background: var(--vscode-textBlockQuote-background, rgba(255,255,255,0.05)); border-radius: 6px; border: 1px solid var(--vscode-panel-border);">
|
||||
<span id="externalUrlDisplay" style="flex: 1; font-size: 11px; color: var(--vscode-descriptionForeground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left;"></span>
|
||||
<button id="externalUrlCopyBtn" title="Copy URL" style="flex-shrink: 0; background: none; border: none; color: var(--vscode-foreground); cursor: pointer; padding: 2px; opacity: 0.7;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p style="margin: 0 0 16px; font-size: 12px; color: var(--vscode-descriptionForeground);">If it didn't open, click the button below.</p>
|
||||
<button id="externalUrlFallbackBtn" style="padding: 8px 20px; background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; border-radius: 6px; cursor: pointer; font-size: 12px;">Open in Browser</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -477,6 +768,9 @@ const getHtml = (isTelemetryEnabled: boolean) => `<!DOCTYPE html>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${getSkillsHtml()}
|
||||
${getPluginsHtml()}
|
||||
|
||||
<!-- Slash commands modal -->
|
||||
<div id="slashCommandsModal" class="tools-modal" style="display: none;">
|
||||
<div class="tools-modal-content">
|
||||
@@ -779,19 +1073,20 @@ const getHtml = (isTelemetryEnabled: boolean) => `<!DOCTYPE html>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${getScript(isTelemetryEnabled)}
|
||||
<script>window.__recommendedModels = ${JSON.stringify(recommendedModels)};window.__topMcpServers = ${JSON.stringify(topMcpServers)};window.__topSkills = ${JSON.stringify(topSkills)};window.__topPlugins = ${JSON.stringify(topPlugins)};</script>
|
||||
${getScript(isTelemetryEnabled, opencreditsApiUrl, opencreditsWebUrl, opencreditsPublishableKey)}
|
||||
|
||||
<!--
|
||||
<!--
|
||||
Analytics FAQ:
|
||||
|
||||
|
||||
1. Is Umami GDPR compliant?
|
||||
Yes, Umami does not collect any personally identifiable information and anonymizes all data collected. Users cannot be identified and are never tracked across websites.
|
||||
|
||||
|
||||
2. Do I need to display a cookie notice to users?
|
||||
No, Umami does not use any cookies in the tracking code.
|
||||
-->
|
||||
${isTelemetryEnabled ? '<script defer src="https://cloud.umami.is/script.js" data-website-id="d050ac9b-2b6d-4c67-b4c6-766432f95644"></script>' : '<!-- Umami analytics disabled due to VS Code telemetry settings -->'}
|
||||
${isTelemetryEnabled ? '<script defer src="https://product.opencredits.ai/script.js" data-website-id="0159e9b1-4a98-4b49-943a-32db3e743b95" data-tag="' + editorName + '@' + extensionVersion + '"></script>' : '<!-- Analytics disabled due to VS Code telemetry settings -->'}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
export default getHtml;
|
||||
export default getHtml;
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
"lib": [
|
||||
"ES2022"
|
||||
],
|
||||
"types": ["node", "mocha"],
|
||||
"sourceMap": true,
|
||||
"rootDir": "src",
|
||||
"resolveJsonModule": true,
|
||||
"strict": true, /* enable all strict type-checking options */
|
||||
/* Additional Checks */
|
||||
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||
@@ -16,6 +18,7 @@
|
||||
},
|
||||
"exclude": [
|
||||
"mcp-permissions.js",
|
||||
"claude-code-chat-permissions-mcp"
|
||||
"claude-code-chat-permissions-mcp",
|
||||
"backup-files"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user