feat: more work on mcp server integration
This commit is contained in:
@@ -1,16 +1,11 @@
|
|||||||
# GitHub Copilot Instructions for Blogging Desktop Server (bDS)
|
# Agents Instructions for Blogging Desktop Server (bDS)
|
||||||
|
|
||||||
This document provides context and best practices for GitHub Copilot when working on this Electron + TypeScript + SQLite blogging application.
|
This document provides context and best practices for GitHub Copilot when working on this Electron + TypeScript + SQLite blogging application.
|
||||||
|
|
||||||
## Project Overview
|
## Plan Mode
|
||||||
|
|
||||||
**Blogging Desktop Server (bDS)** is a desktop blogging application built with:
|
- Make the plan extremely concise. Sacrifice grammar for the sake of concision.
|
||||||
- **Electron** v28+ for cross-platform desktop
|
- At the end of each plan, give me a list of unresolved questions to answer, if any.
|
||||||
- **TypeScript** for all code (strict mode)
|
|
||||||
- **React** for the renderer UI
|
|
||||||
- **Drizzle ORM** for type-safe database access
|
|
||||||
- **@libsql/client** for SQLite (local database)
|
|
||||||
- **Zustand** for React state management
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -143,7 +138,3 @@ This document provides context and best practices for GitHub Copilot when workin
|
|||||||
- Store Dropbox auth tokens in secure storage, not in code
|
- Store Dropbox auth tokens in secure storage, not in code
|
||||||
- Sanitize user input before rendering (XSS prevention)
|
- Sanitize user input before rendering (XSS prevention)
|
||||||
|
|
||||||
## Plan Mode
|
|
||||||
|
|
||||||
- Make the plan extremely concise. Sacrifice grammar for the sake of concision.
|
|
||||||
- At the end of each plan, give me a list of unresolved questions to answer, if any.
|
|
||||||
150
CLAUDE.md
150
CLAUDE.md
@@ -1,149 +1 @@
|
|||||||
# GitHub Copilot Instructions for Blogging Desktop Server (bDS)
|
@AGENTS.md
|
||||||
|
|
||||||
This document provides context and best practices for GitHub Copilot when working on this Electron + TypeScript + SQLite blogging application.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
**Blogging Desktop Server (bDS)** is a desktop blogging application built with:
|
|
||||||
- **Electron** v28+ for cross-platform desktop
|
|
||||||
- **TypeScript** for all code (strict mode)
|
|
||||||
- **React** for the renderer UI
|
|
||||||
- **Drizzle ORM** for type-safe database access
|
|
||||||
- **@libsql/client** for SQLite (local database)
|
|
||||||
- **Zustand** for React state management
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ MANDATORY: Test-First Development
|
|
||||||
|
|
||||||
**STOP!** Before writing ANY implementation code, you MUST:
|
|
||||||
|
|
||||||
1. **Write a failing test first** that describes the expected behavior
|
|
||||||
2. **Run the test** to confirm it fails (Red)
|
|
||||||
3. **Write minimal code** to make the test pass (Green)
|
|
||||||
4. **Refactor** while keeping tests green
|
|
||||||
|
|
||||||
> **No code without tests. No exceptions.**
|
|
||||||
>
|
|
||||||
> Tests must import and exercise the REAL implementation classes, not inline helper functions.
|
|
||||||
> Mock only external dependencies (database, filesystem), never the class under test.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ MANDATORY: Fix All Test Failures
|
|
||||||
|
|
||||||
**You MUST investigate and fix ALL test failures before completing any task.**
|
|
||||||
|
|
||||||
- Never leave tests failing, even if they appear unrelated to your changes
|
|
||||||
- If a test failure is pre-existing, fix it as part of your current work
|
|
||||||
- Run the full test suite (`npm test`) before considering any task complete
|
|
||||||
- If you cannot fix a test, explain why and propose a solution
|
|
||||||
|
|
||||||
> **Zero failing tests. No exceptions.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ MANDATORY: Remove Unused Code
|
|
||||||
|
|
||||||
**Never keep unused code around. Always delete it completely.**
|
|
||||||
|
|
||||||
- When a feature is removed, delete ALL related code (implementation, tests, types, configs)
|
|
||||||
- Do NOT comment out code "for later" - use version control history
|
|
||||||
- Do NOT skip tests for removed functionality - delete them
|
|
||||||
- Do NOT leave dead code paths, unused imports, or orphaned functions
|
|
||||||
- When refactoring, actively look for and remove any code that becomes unused
|
|
||||||
|
|
||||||
> **Delete unused code immediately. No exceptions.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ MANDATORY: Build Verification After Code Changes
|
|
||||||
|
|
||||||
**You MUST run the full build after making code changes.**
|
|
||||||
|
|
||||||
- Run `npm run build` after any code modifications
|
|
||||||
- Fix ALL build errors before considering the task complete
|
|
||||||
- Build errors indicate issues that may not be caught by `tsc --noEmit` alone (e.g., event forwarding, renderer build)
|
|
||||||
- The build must complete successfully before the task is complete
|
|
||||||
|
|
||||||
> **Successful build required. No exceptions.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ MANDATORY: No External JS/CSS in Preview or Generated HTML
|
|
||||||
|
|
||||||
**Do not reference external JavaScript or CSS libraries (CDNs/remote URLs) from the preview server output or generated HTML.**
|
|
||||||
|
|
||||||
- Preview HTML must reference only local/package-bundled assets
|
|
||||||
- Generated HTML must not include CDN-hosted JS/CSS libraries
|
|
||||||
- If a library is needed (e.g., Pico CSS, Lightbox), include it as a local dependency and serve/reference it locally
|
|
||||||
- Avoid introducing any new `<script src="https://...">` or `<link href="https://...">` for library assets in preview/generated output
|
|
||||||
|
|
||||||
> **Preview and generated HTML must be self-contained with local assets. No exceptions.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ MANDATORY: Proper I18N for UI and Rendering Text
|
|
||||||
|
|
||||||
**All user-facing text MUST follow proper i18n patterns.**
|
|
||||||
|
|
||||||
- Do not hardcode UI strings directly in React components, menu templates, dialogs, or toasts
|
|
||||||
- Store UI copy in language resources and resolve text through i18n helpers/hooks
|
|
||||||
- UI language MUST come from the operating system locale
|
|
||||||
- Rendering/preview/generated-content language MUST come from project preferences (`mainLanguage`), not UI locale
|
|
||||||
- Keep i18n usage consistent in both renderer UI and render/preview output
|
|
||||||
- For supported locales, translations MUST come from that locale file only; do not copy English terms into supported locale files as fallback
|
|
||||||
- English fallback is allowed only when the requested locale is unsupported by available locale files
|
|
||||||
- The project `mainLanguage` selector must expose exactly all supported render languages and no unsupported extras
|
|
||||||
|
|
||||||
> **No hardcoded user-facing text. No exceptions.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ MANDATORY: Keep Python API Bindings and API Docs in Sync
|
|
||||||
|
|
||||||
**Whenever any app API is added, removed, or changed, you MUST update the Python API bridge and API documentation in the same change set.**
|
|
||||||
|
|
||||||
- Update the Python API contract/bindings used by embedded Pyodide (`bds_api`)
|
|
||||||
- Regenerate and commit `API.md`
|
|
||||||
- Ensure every API entry documents:
|
|
||||||
- Parameter names, types, and required/optional status
|
|
||||||
- Return type/response specification
|
|
||||||
- At least one sample Python call
|
|
||||||
- Maintain a shared **Data Structures** section in `API.md` for canonical objects (for example `PostData`, `MediaData`) so users can see expected attributes in one place
|
|
||||||
- Keep docs sync tests passing (documentation and generator output must match)
|
|
||||||
|
|
||||||
> **No API contract drift between app APIs, Python bindings, and API.md. No exceptions.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture Principles
|
|
||||||
|
|
||||||
### Separation of Concerns
|
|
||||||
|
|
||||||
1. **Engine Classes** (`src/main/engine/`): All business logic lives here
|
|
||||||
- No UI code, no IPC code, no direct database or filesystem access
|
|
||||||
- Methods should be pure functions where possible, with side effects (fs/db/events) abstracted behind interfaces
|
|
||||||
|
|
||||||
2. **IPC Layer** (`src/main/ipc/`): Bridge between main and renderer
|
|
||||||
- Handles all IPC communication, input validation, and error handling
|
|
||||||
- Calls engine methods and forwards results/events to renderer
|
|
||||||
- No business logic or UI code here
|
|
||||||
|
|
||||||
3. **UI Components** (`src/renderer/components/`): Presentation only
|
|
||||||
- Components should be stateless where possible
|
|
||||||
- Use Zustand store for shared state
|
|
||||||
- Never call IPC directly from deeply nested components
|
|
||||||
|
|
||||||
## Security Reminders
|
|
||||||
|
|
||||||
- Never log sensitive data (auth tokens, passwords)
|
|
||||||
- Validate all IPC inputs before processing
|
|
||||||
- Use `contextIsolation: true` and `sandbox: false` only when necessary
|
|
||||||
- Store Dropbox auth tokens in secure storage, not in code
|
|
||||||
- Sanitize user input before rendering (XSS prevention)
|
|
||||||
|
|
||||||
## Plan Mode
|
|
||||||
|
|
||||||
- Make the plan extremely concise. Sacrifice grammar for the sake of concision.
|
|
||||||
- At the end of each plan, give me a list of unresolved questions to answer, if any.
|
|
||||||
|
|||||||
149
GEMINI.md
149
GEMINI.md
@@ -1,149 +0,0 @@
|
|||||||
# GitHub Copilot Instructions for Blogging Desktop Server (bDS)
|
|
||||||
|
|
||||||
This document provides context and best practices for GitHub Copilot when working on this Electron + TypeScript + SQLite blogging application.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
**Blogging Desktop Server (bDS)** is a desktop blogging application built with:
|
|
||||||
- **Electron** v28+ for cross-platform desktop
|
|
||||||
- **TypeScript** for all code (strict mode)
|
|
||||||
- **React** for the renderer UI
|
|
||||||
- **Drizzle ORM** for type-safe database access
|
|
||||||
- **@libsql/client** for SQLite (local database)
|
|
||||||
- **Zustand** for React state management
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ MANDATORY: Test-First Development
|
|
||||||
|
|
||||||
**STOP!** Before writing ANY implementation code, you MUST:
|
|
||||||
|
|
||||||
1. **Write a failing test first** that describes the expected behavior
|
|
||||||
2. **Run the test** to confirm it fails (Red)
|
|
||||||
3. **Write minimal code** to make the test pass (Green)
|
|
||||||
4. **Refactor** while keeping tests green
|
|
||||||
|
|
||||||
> **No code without tests. No exceptions.**
|
|
||||||
>
|
|
||||||
> Tests must import and exercise the REAL implementation classes, not inline helper functions.
|
|
||||||
> Mock only external dependencies (database, filesystem), never the class under test.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ MANDATORY: Fix All Test Failures
|
|
||||||
|
|
||||||
**You MUST investigate and fix ALL test failures before completing any task.**
|
|
||||||
|
|
||||||
- Never leave tests failing, even if they appear unrelated to your changes
|
|
||||||
- If a test failure is pre-existing, fix it as part of your current work
|
|
||||||
- Run the full test suite (`npm test`) before considering any task complete
|
|
||||||
- If you cannot fix a test, explain why and propose a solution
|
|
||||||
|
|
||||||
> **Zero failing tests. No exceptions.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ MANDATORY: Remove Unused Code
|
|
||||||
|
|
||||||
**Never keep unused code around. Always delete it completely.**
|
|
||||||
|
|
||||||
- When a feature is removed, delete ALL related code (implementation, tests, types, configs)
|
|
||||||
- Do NOT comment out code "for later" - use version control history
|
|
||||||
- Do NOT skip tests for removed functionality - delete them
|
|
||||||
- Do NOT leave dead code paths, unused imports, or orphaned functions
|
|
||||||
- When refactoring, actively look for and remove any code that becomes unused
|
|
||||||
|
|
||||||
> **Delete unused code immediately. No exceptions.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ MANDATORY: Build Verification After Code Changes
|
|
||||||
|
|
||||||
**You MUST run the full build after making code changes.**
|
|
||||||
|
|
||||||
- Run `npm run build` after any code modifications
|
|
||||||
- Fix ALL build errors before considering the task complete
|
|
||||||
- Build errors indicate issues that may not be caught by `tsc --noEmit` alone (e.g., event forwarding, renderer build)
|
|
||||||
- The build must complete successfully before the task is complete
|
|
||||||
|
|
||||||
> **Successful build required. No exceptions.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ MANDATORY: No External JS/CSS in Preview or Generated HTML
|
|
||||||
|
|
||||||
**Do not reference external JavaScript or CSS libraries (CDNs/remote URLs) from the preview server output or generated HTML.**
|
|
||||||
|
|
||||||
- Preview HTML must reference only local/package-bundled assets
|
|
||||||
- Generated HTML must not include CDN-hosted JS/CSS libraries
|
|
||||||
- If a library is needed (e.g., Pico CSS, Lightbox), include it as a local dependency and serve/reference it locally
|
|
||||||
- Avoid introducing any new `<script src="https://...">` or `<link href="https://...">` for library assets in preview/generated output
|
|
||||||
|
|
||||||
> **Preview and generated HTML must be self-contained with local assets. No exceptions.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ MANDATORY: Proper I18N for UI and Rendering Text
|
|
||||||
|
|
||||||
**All user-facing text MUST follow proper i18n patterns.**
|
|
||||||
|
|
||||||
- Do not hardcode UI strings directly in React components, menu templates, dialogs, or toasts
|
|
||||||
- Store UI copy in language resources and resolve text through i18n helpers/hooks
|
|
||||||
- UI language MUST come from the operating system locale
|
|
||||||
- Rendering/preview/generated-content language MUST come from project preferences (`mainLanguage`), not UI locale
|
|
||||||
- Keep i18n usage consistent in both renderer UI and render/preview output
|
|
||||||
- For supported locales, translations MUST come from that locale file only; do not copy English terms into supported locale files as fallback
|
|
||||||
- English fallback is allowed only when the requested locale is unsupported by available locale files
|
|
||||||
- The project `mainLanguage` selector must expose exactly all supported render languages and no unsupported extras
|
|
||||||
|
|
||||||
> **No hardcoded user-facing text. No exceptions.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ MANDATORY: Keep Python API Bindings and API Docs in Sync
|
|
||||||
|
|
||||||
**Whenever any app API is added, removed, or changed, you MUST update the Python API bridge and API documentation in the same change set.**
|
|
||||||
|
|
||||||
- Update the Python API contract/bindings used by embedded Pyodide (`bds_api`)
|
|
||||||
- Regenerate and commit `API.md`
|
|
||||||
- Ensure every API entry documents:
|
|
||||||
- Parameter names, types, and required/optional status
|
|
||||||
- Return type/response specification
|
|
||||||
- At least one sample Python call
|
|
||||||
- Maintain a shared **Data Structures** section in `API.md` for canonical objects (for example `PostData`, `MediaData`) so users can see expected attributes in one place
|
|
||||||
- Keep docs sync tests passing (documentation and generator output must match)
|
|
||||||
|
|
||||||
> **No API contract drift between app APIs, Python bindings, and API.md. No exceptions.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture Principles
|
|
||||||
|
|
||||||
### Separation of Concerns
|
|
||||||
|
|
||||||
1. **Engine Classes** (`src/main/engine/`): All business logic lives here
|
|
||||||
- No UI code, no IPC code, no direct database or filesystem access
|
|
||||||
- Methods should be pure functions where possible, with side effects (fs/db/events) abstracted behind interfaces
|
|
||||||
|
|
||||||
2. **IPC Layer** (`src/main/ipc/`): Bridge between main and renderer
|
|
||||||
- Handles all IPC communication, input validation, and error handling
|
|
||||||
- Calls engine methods and forwards results/events to renderer
|
|
||||||
- No business logic or UI code here
|
|
||||||
|
|
||||||
3. **UI Components** (`src/renderer/components/`): Presentation only
|
|
||||||
- Components should be stateless where possible
|
|
||||||
- Use Zustand store for shared state
|
|
||||||
- Never call IPC directly from deeply nested components
|
|
||||||
|
|
||||||
## Security Reminders
|
|
||||||
|
|
||||||
- Never log sensitive data (auth tokens, passwords)
|
|
||||||
- Validate all IPC inputs before processing
|
|
||||||
- Use `contextIsolation: true` and `sandbox: false` only when necessary
|
|
||||||
- Store Dropbox auth tokens in secure storage, not in code
|
|
||||||
- Sanitize user input before rendering (XSS prevention)
|
|
||||||
|
|
||||||
## Plan Mode
|
|
||||||
|
|
||||||
- Make the plan extremely concise. Sacrifice grammar for the sake of concision.
|
|
||||||
- At the end of each plan, give me a list of unresolved questions to answer, if any.
|
|
||||||
@@ -152,6 +152,10 @@
|
|||||||
{
|
{
|
||||||
"from": "src/main/engine/templates",
|
"from": "src/main/engine/templates",
|
||||||
"to": "templates"
|
"to": "templates"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "src/main/engine/mcp-views",
|
||||||
|
"to": "mcp-views"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"protocols": [
|
"protocols": [
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
|
|||||||
const match = candidates.find((candidate) => {
|
const match = candidates.find((candidate) => {
|
||||||
const createdAt = candidate.createdAt;
|
const createdAt = candidate.createdAt;
|
||||||
return createdAt.getFullYear() === dateFilter.year
|
return createdAt.getFullYear() === dateFilter.year
|
||||||
&& createdAt.getMonth() === dateFilter.month;
|
&& createdAt.getMonth() === dateFilter.month - 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
return match ?? null;
|
return match ?? null;
|
||||||
|
|||||||
156
src/main/engine/MCPAgentConfigEngine.ts
Normal file
156
src/main/engine/MCPAgentConfigEngine.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* MCPAgentConfigEngine – adds the bDS MCP server entry to coding-agent config files.
|
||||||
|
*
|
||||||
|
* Supports: Claude Code, GitHub Copilot (VS Code), Gemini CLI, OpenCode.
|
||||||
|
* Each agent has its own config file format; this engine reads, merges, and writes
|
||||||
|
* the appropriate JSON structure without overwriting existing entries.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// ── Public types ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type MCPAgentId = 'claude-code' | 'github-copilot' | 'gemini-cli' | 'opencode';
|
||||||
|
|
||||||
|
export interface AgentDefinition {
|
||||||
|
id: MCPAgentId;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentConfigResult {
|
||||||
|
success: boolean;
|
||||||
|
configPath: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MCPAgentConfigOptions {
|
||||||
|
homeDir: string;
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
mcpUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agent definitions ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const AGENTS: AgentDefinition[] = [
|
||||||
|
{ id: 'claude-code', label: 'Claude Code' },
|
||||||
|
{ id: 'github-copilot', label: 'GitHub Copilot' },
|
||||||
|
{ id: 'gemini-cli', label: 'Gemini CLI' },
|
||||||
|
{ id: 'opencode', label: 'OpenCode' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SERVER_NAME = 'bDS';
|
||||||
|
|
||||||
|
// ── Engine ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class MCPAgentConfigEngine {
|
||||||
|
private readonly homeDir: string;
|
||||||
|
private readonly platform: NodeJS.Platform;
|
||||||
|
private readonly mcpUrl: string;
|
||||||
|
|
||||||
|
constructor(opts: MCPAgentConfigOptions) {
|
||||||
|
this.homeDir = opts.homeDir;
|
||||||
|
this.platform = opts.platform;
|
||||||
|
this.mcpUrl = opts.mcpUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the list of supported agent definitions. */
|
||||||
|
getAgents(): AgentDefinition[] {
|
||||||
|
return [...AGENTS];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the absolute path to the config file for the given agent. */
|
||||||
|
getConfigPath(agentId: MCPAgentId): string {
|
||||||
|
switch (agentId) {
|
||||||
|
case 'claude-code':
|
||||||
|
return path.join(this.homeDir, '.claude.json');
|
||||||
|
case 'github-copilot':
|
||||||
|
return this.vsCodeMcpPath();
|
||||||
|
case 'gemini-cli':
|
||||||
|
return path.join(this.homeDir, '.gemini', 'settings.json');
|
||||||
|
case 'opencode':
|
||||||
|
return path.join(this.homeDir, '.opencode.json');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-merge-write the bDS MCP server entry into the agent's config file. */
|
||||||
|
addToConfig(agentId: MCPAgentId): AgentConfigResult {
|
||||||
|
const configPath = this.getConfigPath(agentId);
|
||||||
|
try {
|
||||||
|
const existing = this.readExisting(configPath);
|
||||||
|
const merged = this.merge(agentId, existing);
|
||||||
|
this.ensureDir(configPath);
|
||||||
|
writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
|
||||||
|
return { success: true, configPath };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return { success: false, configPath, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether the bDS entry already exists in the agent's config. */
|
||||||
|
isConfigured(agentId: MCPAgentId): boolean {
|
||||||
|
const configPath = this.getConfigPath(agentId);
|
||||||
|
if (!existsSync(configPath)) return false;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
const serversKey = agentId === 'github-copilot' ? 'servers' : 'mcpServers';
|
||||||
|
return !!data?.[serversKey]?.[SERVER_NAME];
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
private vsCodeMcpPath(): string {
|
||||||
|
if (this.platform === 'darwin') {
|
||||||
|
return path.join(this.homeDir, 'Library', 'Application Support', 'Code', 'User', 'mcp.json');
|
||||||
|
}
|
||||||
|
if (this.platform === 'win32') {
|
||||||
|
return path.join(this.homeDir, 'AppData', 'Roaming', 'Code', 'User', 'mcp.json');
|
||||||
|
}
|
||||||
|
// linux and others
|
||||||
|
return path.join(this.homeDir, '.config', 'Code', 'User', 'mcp.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
private readExisting(configPath: string): Record<string, unknown> {
|
||||||
|
if (!existsSync(configPath)) return {};
|
||||||
|
const raw = readFileSync(configPath, 'utf-8');
|
||||||
|
return JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private merge(agentId: MCPAgentId, existing: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const entry = this.buildEntry(agentId);
|
||||||
|
const serversKey = agentId === 'github-copilot' ? 'servers' : 'mcpServers';
|
||||||
|
const currentServers = (existing[serversKey] as Record<string, unknown> | undefined) ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...existing,
|
||||||
|
[serversKey]: {
|
||||||
|
...currentServers,
|
||||||
|
[SERVER_NAME]: entry,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildEntry(agentId: MCPAgentId): Record<string, unknown> {
|
||||||
|
switch (agentId) {
|
||||||
|
case 'claude-code':
|
||||||
|
return { type: 'http', url: this.mcpUrl };
|
||||||
|
case 'github-copilot':
|
||||||
|
return { type: 'http', url: this.mcpUrl };
|
||||||
|
case 'gemini-cli':
|
||||||
|
return { httpUrl: this.mcpUrl };
|
||||||
|
case 'opencode':
|
||||||
|
return { type: 'sse', url: this.mcpUrl };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureDir(filePath: string): void {
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -818,9 +818,9 @@ export class MediaEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filter.month !== undefined && filter.year !== undefined) {
|
if (filter.month !== undefined && filter.year !== undefined) {
|
||||||
// Use UTC dates to avoid timezone issues
|
// Use UTC dates to avoid timezone issues (filter.month is 1-indexed)
|
||||||
const startOfMonth = new Date(Date.UTC(filter.year, filter.month, 1));
|
const startOfMonth = new Date(Date.UTC(filter.year, filter.month - 1, 1));
|
||||||
const endOfMonth = new Date(Date.UTC(filter.year, filter.month + 1, 1));
|
const endOfMonth = new Date(Date.UTC(filter.year, filter.month, 1));
|
||||||
console.log(`[MediaEngine] Month filter: ${startOfMonth.toISOString()} to ${endOfMonth.toISOString()}`);
|
console.log(`[MediaEngine] Month filter: ${startOfMonth.toISOString()} to ${endOfMonth.toISOString()}`);
|
||||||
conditions.push(gte(media.createdAt, startOfMonth));
|
conditions.push(gte(media.createdAt, startOfMonth));
|
||||||
conditions.push(lt(media.createdAt, endOfMonth));
|
conditions.push(lt(media.createdAt, endOfMonth));
|
||||||
@@ -912,7 +912,7 @@ export class MediaEngine extends EventEmitter {
|
|||||||
|
|
||||||
for (const item of allMedia) {
|
for (const item of allMedia) {
|
||||||
const year = item.createdAt.getFullYear();
|
const year = item.createdAt.getFullYear();
|
||||||
const month = item.createdAt.getMonth();
|
const month = item.createdAt.getMonth() + 1; // 1-indexed
|
||||||
const key = `${year}-${month}`;
|
const key = `${year}-${month}`;
|
||||||
const current = counts.get(key) || { year, month, count: 0 };
|
const current = counts.get(key) || { year, month, count: 0 };
|
||||||
current.count++;
|
current.count++;
|
||||||
|
|||||||
@@ -1264,21 +1264,11 @@ export class OpenCodeManager {
|
|||||||
const limit = (args.limit as number) || 10;
|
const limit = (args.limit as number) || 10;
|
||||||
|
|
||||||
let filteredPosts;
|
let filteredPosts;
|
||||||
if (hasFilters) {
|
// Use searchPostsFiltered for all paths — it handles FTS + structural
|
||||||
// Combined FTS + structural filters in a single SQL query
|
// filters in a single SQL JOIN and returns full PostData[]
|
||||||
filteredPosts = await this.postEngine.searchPostsFiltered(
|
filteredPosts = await this.postEngine.searchPostsFiltered(
|
||||||
args.query as string, filter, { offset, limit },
|
args.query as string, filter, { offset, limit },
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
// Pure FTS search
|
|
||||||
const searchResults = await this.postEngine.searchPosts(args.query as string);
|
|
||||||
// searchPosts returns sparse results; fetch full post data
|
|
||||||
const fullPosts = await Promise.all(
|
|
||||||
searchResults.map(sr => this.postEngine.getPost(sr.id))
|
|
||||||
);
|
|
||||||
const all = fullPosts.filter(p => p !== null) as PostData[];
|
|
||||||
filteredPosts = all.slice(offset, offset + limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalMatches = filteredPosts.length;
|
const totalMatches = filteredPosts.length;
|
||||||
|
|
||||||
@@ -1320,7 +1310,7 @@ export class OpenCodeManager {
|
|||||||
if (args.tags) filter.tags = args.tags as string[];
|
if (args.tags) filter.tags = args.tags as string[];
|
||||||
if (args.category) filter.categories = [args.category as string];
|
if (args.category) filter.categories = [args.category as string];
|
||||||
if (args.year !== undefined) filter.year = args.year as number;
|
if (args.year !== undefined) filter.year = args.year as number;
|
||||||
if (args.month !== undefined && args.year !== undefined) filter.month = (args.month as number) - 1; // Convert 1-indexed to 0-indexed
|
if (args.month !== undefined && args.year !== undefined) filter.month = args.month as number;
|
||||||
|
|
||||||
const offset = (args.offset as number) || 0;
|
const offset = (args.offset as number) || 0;
|
||||||
const limit = (args.limit as number) || 20;
|
const limit = (args.limit as number) || 20;
|
||||||
@@ -1380,7 +1370,7 @@ export class OpenCodeManager {
|
|||||||
if (hasMediaFilter) {
|
if (hasMediaFilter) {
|
||||||
const mediaFilter: { year?: number; month?: number; tags?: string[] } = {};
|
const mediaFilter: { year?: number; month?: number; tags?: string[] } = {};
|
||||||
if (args.year !== undefined) mediaFilter.year = args.year as number;
|
if (args.year !== undefined) mediaFilter.year = args.year as number;
|
||||||
if (args.month !== undefined && args.year !== undefined) mediaFilter.month = (args.month as number) - 1; // Convert 1-indexed to 0-indexed
|
if (args.month !== undefined && args.year !== undefined) mediaFilter.month = args.month as number;
|
||||||
if (args.tags) mediaFilter.tags = args.tags as string[];
|
if (args.tags) mediaFilter.tags = args.tags as string[];
|
||||||
mediaList = await this.mediaEngine.getMediaFiltered(mediaFilter);
|
mediaList = await this.mediaEngine.getMediaFiltered(mediaFilter);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -770,8 +770,8 @@ export class PostEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filter.month !== undefined && filter.year !== undefined) {
|
if (filter.month !== undefined && filter.year !== undefined) {
|
||||||
const startOfMonth = new Date(filter.year, filter.month, 1);
|
const startOfMonth = new Date(filter.year, filter.month - 1, 1);
|
||||||
const endOfMonth = new Date(filter.year, filter.month + 1, 1);
|
const endOfMonth = new Date(filter.year, filter.month, 1);
|
||||||
conditions.push(gte(posts.createdAt, startOfMonth));
|
conditions.push(gte(posts.createdAt, startOfMonth));
|
||||||
conditions.push(lte(posts.createdAt, endOfMonth));
|
conditions.push(lte(posts.createdAt, endOfMonth));
|
||||||
}
|
}
|
||||||
@@ -1126,7 +1126,7 @@ export class PostEngine extends EventEmitter {
|
|||||||
|
|
||||||
for (const post of allPosts) {
|
for (const post of allPosts) {
|
||||||
const year = post.createdAt.getFullYear();
|
const year = post.createdAt.getFullYear();
|
||||||
const month = post.createdAt.getMonth();
|
const month = post.createdAt.getMonth() + 1; // 1-indexed
|
||||||
const key = `${year}-${month}`;
|
const key = `${year}-${month}`;
|
||||||
const current = counts.get(key) || { year, month, count: 0 };
|
const current = counts.get(key) || { year, month, count: 0 };
|
||||||
current.count++;
|
current.count++;
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ async function resolveRouteWithSharedServices(
|
|||||||
const month = Number(daySlugMatch[2]);
|
const month = Number(daySlugMatch[2]);
|
||||||
const day = Number(daySlugMatch[3]);
|
const day = Number(daySlugMatch[3]);
|
||||||
const slug = daySlugMatch[4];
|
const slug = daySlugMatch[4];
|
||||||
const post = await services.findSinglePostBySlug(slug, singlePostOptions, { year, month: month - 1, day });
|
const post = await services.findSinglePostBySlug(slug, singlePostOptions, { year, month, day });
|
||||||
if (!post) return null;
|
if (!post) return null;
|
||||||
return services.pageRenderer.renderSinglePost(post, rewriteContext, {
|
return services.pageRenderer.renderSinglePost(post, rewriteContext, {
|
||||||
page_title: pageContext.pageTitle,
|
page_title: pageContext.pageTitle,
|
||||||
@@ -224,7 +224,7 @@ async function resolveRouteWithSharedServices(
|
|||||||
const year = Number(monthMatch[1]);
|
const year = Number(monthMatch[1]);
|
||||||
const month = Number(monthMatch[2]);
|
const month = Number(monthMatch[2]);
|
||||||
if (month < 1 || month > 12) return null;
|
if (month < 1 || month > 12) return null;
|
||||||
const result = await services.loadPublishedSnapshotsPage({ status: 'published', year, month: month - 1, excludeCategories: listExcludedCategories }, pageOptions);
|
const result = await services.loadPublishedSnapshotsPage({ status: 'published', year, month, excludeCategories: listExcludedCategories }, pageOptions);
|
||||||
return services.pageRenderer.renderPostList(result.posts, rewriteContext, {
|
return services.pageRenderer.renderPostList(result.posts, rewriteContext, {
|
||||||
archiveGrouping: true,
|
archiveGrouping: true,
|
||||||
routeKind: 'date',
|
routeKind: 'date',
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export async function findSinglePostBySlug(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sameYear = draftCandidate.createdAt.getFullYear() === dateFilter.year;
|
const sameYear = draftCandidate.createdAt.getFullYear() === dateFilter.year;
|
||||||
const sameMonth = draftCandidate.createdAt.getMonth() === dateFilter.month;
|
const sameMonth = draftCandidate.createdAt.getMonth() === dateFilter.month - 1;
|
||||||
const sameDay = dateFilter.day === undefined || draftCandidate.createdAt.getDate() === dateFilter.day;
|
const sameDay = dateFilter.day === undefined || draftCandidate.createdAt.getDate() === dateFilter.day;
|
||||||
if (sameYear && sameMonth && sameDay) {
|
if (sameYear && sameMonth && sameDay) {
|
||||||
return draftCandidate;
|
return draftCandidate;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* MCP App Review Views — inline HTML strings for review UIs.
|
* MCP App Review Views — loaded from HTML files in the `mcp-views/` directory.
|
||||||
*
|
*
|
||||||
* Each function returns a self-contained HTML page that uses the
|
* Each function returns a self-contained HTML page that uses the
|
||||||
* `App` class from `@modelcontextprotocol/ext-apps` (loaded via
|
* `App` class from `@modelcontextprotocol/ext-apps` (loaded via
|
||||||
@@ -9,250 +9,77 @@
|
|||||||
* in MCP hosts that support MCP Apps (Claude, ChatGPT, VS Code, etc.).
|
* in MCP hosts that support MCP Apps (Claude, ChatGPT, VS Code, etc.).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function baseStyles(): string {
|
import { readFileSync } from 'fs';
|
||||||
return `
|
import path from 'path';
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; padding: 16px; color: #1a1a1a; background: #fff; line-height: 1.5; }
|
/**
|
||||||
h1 { font-size: 1.25rem; margin-bottom: 12px; }
|
* Resolve candidate directories for MCP view HTML files.
|
||||||
h2 { font-size: 1rem; margin: 12px 0 8px; color: #555; }
|
* Checks `__dirname/mcp-views`, `dist/main/engine/mcp-views`,
|
||||||
.meta { color: #666; font-size: 0.875rem; margin-bottom: 8px; }
|
* `src/main/engine/mcp-views`, and `process.resourcesPath/mcp-views`.
|
||||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
|
*/
|
||||||
.badge-draft { background: #fef3cd; color: #856404; }
|
export function resolveMcpViewsDirs(options?: {
|
||||||
.badge-kind { background: #d1ecf1; color: #0c5460; }
|
moduleDir?: string;
|
||||||
.content-preview { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 12px; margin: 8px 0; overflow-x: auto; white-space: pre-wrap; font-family: monospace; font-size: 0.85rem; max-height: 400px; overflow-y: auto; }
|
cwd?: string;
|
||||||
.actions { display: flex; gap: 8px; margin-top: 16px; }
|
resourcesPath?: string;
|
||||||
.btn { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem; font-weight: 500; }
|
}): string[] {
|
||||||
.btn-accept { background: #28a745; color: #fff; }
|
const moduleDir = options?.moduleDir ?? __dirname;
|
||||||
.btn-accept:hover { background: #218838; }
|
const cwd = options?.cwd ?? process.cwd();
|
||||||
.btn-discard { background: #dc3545; color: #fff; }
|
const resourcesPath = options?.resourcesPath ?? process.resourcesPath;
|
||||||
.btn-discard:hover { background: #c82333; }
|
|
||||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
const dirs = [
|
||||||
.status { margin-top: 12px; padding: 8px 12px; border-radius: 6px; font-size: 0.875rem; }
|
path.resolve(moduleDir, 'mcp-views'),
|
||||||
.status-success { background: #d4edda; color: #155724; }
|
path.resolve(cwd, 'dist', 'main', 'engine', 'mcp-views'),
|
||||||
.status-error { background: #f8d7da; color: #721c24; }
|
path.resolve(cwd, 'src', 'main', 'engine', 'mcp-views'),
|
||||||
.diff-table { width: 100%; border-collapse: collapse; margin: 8px 0; }
|
];
|
||||||
.diff-table th, .diff-table td { padding: 6px 10px; border: 1px solid #dee2e6; text-align: left; font-size: 0.85rem; }
|
|
||||||
.diff-table th { background: #f1f3f5; font-weight: 600; }
|
if (typeof resourcesPath === 'string' && resourcesPath.length > 0) {
|
||||||
.diff-old { background: #ffeef0; }
|
dirs.unshift(path.resolve(resourcesPath, 'mcp-views'));
|
||||||
.diff-new { background: #e6ffed; }
|
|
||||||
.word-count { color: #888; font-size: 0.8rem; }
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function appScript(): string {
|
return Array.from(new Set(dirs));
|
||||||
return `
|
}
|
||||||
import { App } from "@modelcontextprotocol/ext-apps/app-with-deps";
|
|
||||||
|
|
||||||
const app = new App({ name: "bDS Review", version: "1.0.0" });
|
/**
|
||||||
|
* Load an MCP view HTML file by name, searching candidate directories.
|
||||||
let currentData = null;
|
* Throws if the file cannot be found in any candidate directory.
|
||||||
|
*/
|
||||||
app.ontoolresult = (result) => {
|
export function loadViewHtml(
|
||||||
|
filename: string,
|
||||||
|
options?: Parameters<typeof resolveMcpViewsDirs>[0],
|
||||||
|
): string {
|
||||||
|
const dirs = resolveMcpViewsDirs(options);
|
||||||
|
for (const dir of dirs) {
|
||||||
try {
|
try {
|
||||||
const textContent = result.content?.find(c => c.type === "text");
|
return readFileSync(path.join(dir, filename), 'utf-8');
|
||||||
if (textContent?.text) {
|
} catch {
|
||||||
currentData = JSON.parse(textContent.text);
|
// try next directory
|
||||||
renderReview(currentData);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
showStatus("Failed to parse tool result: " + e.message, "error");
|
|
||||||
}
|
}
|
||||||
};
|
throw new Error(
|
||||||
|
`MCP view "${filename}" not found in any of: ${dirs.join(', ')}`,
|
||||||
window.acceptProposal = async () => {
|
);
|
||||||
if (!currentData?.proposalId) return;
|
|
||||||
setButtonsDisabled(true);
|
|
||||||
try {
|
|
||||||
const result = await app.callServerTool({
|
|
||||||
name: "accept_proposal",
|
|
||||||
arguments: { proposalId: currentData.proposalId }
|
|
||||||
});
|
|
||||||
const text = result.content?.find(c => c.type === "text")?.text;
|
|
||||||
const parsed = text ? JSON.parse(text) : {};
|
|
||||||
showStatus(parsed.success ? "Accepted!" : (parsed.message || "Failed"), parsed.success ? "success" : "error");
|
|
||||||
} catch (e) {
|
|
||||||
showStatus("Error: " + e.message, "error");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.discardProposal = async () => {
|
|
||||||
if (!currentData?.proposalId) return;
|
|
||||||
setButtonsDisabled(true);
|
|
||||||
try {
|
|
||||||
const result = await app.callServerTool({
|
|
||||||
name: "discard_proposal",
|
|
||||||
arguments: { proposalId: currentData.proposalId }
|
|
||||||
});
|
|
||||||
const text = result.content?.find(c => c.type === "text")?.text;
|
|
||||||
const parsed = text ? JSON.parse(text) : {};
|
|
||||||
showStatus(parsed.success ? "Discarded." : (parsed.message || "Failed"), parsed.success ? "success" : "error");
|
|
||||||
} catch (e) {
|
|
||||||
showStatus("Error: " + e.message, "error");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function setButtonsDisabled(disabled) {
|
|
||||||
document.querySelectorAll(".btn").forEach(b => b.disabled = disabled);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showStatus(message, type) {
|
export function reviewPostHtml(
|
||||||
const el = document.getElementById("status");
|
options?: Parameters<typeof resolveMcpViewsDirs>[0],
|
||||||
if (el) {
|
): string {
|
||||||
el.textContent = message;
|
return loadViewHtml('review-post.html', options);
|
||||||
el.className = "status status-" + type;
|
|
||||||
el.style.display = "block";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.showStatus = showStatus;
|
export function reviewScriptHtml(
|
||||||
window.renderReview = window.renderReview || (() => {});
|
options?: Parameters<typeof resolveMcpViewsDirs>[0],
|
||||||
|
): string {
|
||||||
app.connect().catch(e => console.error("App connect failed:", e));
|
return loadViewHtml('review-script.html', options);
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reviewPostHtml(): string {
|
export function reviewTemplateHtml(
|
||||||
return `<!DOCTYPE html>
|
options?: Parameters<typeof resolveMcpViewsDirs>[0],
|
||||||
<html lang="en">
|
): string {
|
||||||
<head><meta charset="UTF-8"><title>Review Post</title>
|
return loadViewHtml('review-template.html', options);
|
||||||
<style>${baseStyles()}</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="review">
|
|
||||||
<p class="meta">Waiting for post data...</p>
|
|
||||||
</div>
|
|
||||||
<div id="status" class="status" style="display:none"></div>
|
|
||||||
<script type="module">
|
|
||||||
${appScript()}
|
|
||||||
window.renderReview = (data) => {
|
|
||||||
const post = data.post || {};
|
|
||||||
const wc = (post.content || "").split(/\\s+/).filter(Boolean).length;
|
|
||||||
document.getElementById("review").innerHTML = \`
|
|
||||||
<h1>\${esc(post.title || "Untitled")}</h1>
|
|
||||||
<p class="meta">
|
|
||||||
<span class="badge badge-draft">Draft</span>
|
|
||||||
<span class="word-count">\${wc} words</span>
|
|
||||||
</p>
|
|
||||||
\${post.categories?.length ? '<p class="meta">Categories: ' + post.categories.map(c => esc(c)).join(", ") + '</p>' : ''}
|
|
||||||
\${post.tags?.length ? '<p class="meta">Tags: ' + post.tags.map(t => esc(t)).join(", ") + '</p>' : ''}
|
|
||||||
\${post.excerpt ? '<h2>Excerpt</h2><p>' + esc(post.excerpt) + '</p>' : ''}
|
|
||||||
<h2>Content</h2>
|
|
||||||
<div class="content-preview">\${esc(post.content || "")}</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button class="btn btn-accept" onclick="acceptProposal()">Publish</button>
|
|
||||||
<button class="btn btn-discard" onclick="discardProposal()">Discard Draft</button>
|
|
||||||
</div>
|
|
||||||
\`;
|
|
||||||
};
|
|
||||||
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reviewScriptHtml(): string {
|
export function reviewMetadataHtml(
|
||||||
return `<!DOCTYPE html>
|
options?: Parameters<typeof resolveMcpViewsDirs>[0],
|
||||||
<html lang="en">
|
): string {
|
||||||
<head><meta charset="UTF-8"><title>Review Script</title>
|
return loadViewHtml('review-metadata.html', options);
|
||||||
<style>${baseStyles()}</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="review">
|
|
||||||
<p class="meta">Waiting for script data...</p>
|
|
||||||
</div>
|
|
||||||
<div id="status" class="status" style="display:none"></div>
|
|
||||||
<script type="module">
|
|
||||||
${appScript()}
|
|
||||||
window.renderReview = (data) => {
|
|
||||||
const p = data.preview || data;
|
|
||||||
document.getElementById("review").innerHTML = \`
|
|
||||||
<h1>\${esc(p.title || "Untitled Script")}</h1>
|
|
||||||
<p class="meta"><span class="badge badge-kind">\${esc(p.kind || "script")}</span></p>
|
|
||||||
<h2>Python Code</h2>
|
|
||||||
<div class="content-preview">\${esc(p.content || "(code not included in preview)")}</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button class="btn btn-accept" onclick="acceptProposal()">Create Script</button>
|
|
||||||
<button class="btn btn-discard" onclick="discardProposal()">Discard</button>
|
|
||||||
</div>
|
|
||||||
\`;
|
|
||||||
};
|
|
||||||
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reviewTemplateHtml(): string {
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head><meta charset="UTF-8"><title>Review Template</title>
|
|
||||||
<style>${baseStyles()}</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="review">
|
|
||||||
<p class="meta">Waiting for template data...</p>
|
|
||||||
</div>
|
|
||||||
<div id="status" class="status" style="display:none"></div>
|
|
||||||
<script type="module">
|
|
||||||
${appScript()}
|
|
||||||
window.renderReview = (data) => {
|
|
||||||
const p = data.preview || data;
|
|
||||||
document.getElementById("review").innerHTML = \`
|
|
||||||
<h1>\${esc(p.title || "Untitled Template")}</h1>
|
|
||||||
<p class="meta"><span class="badge badge-kind">\${esc(p.kind || "template")}</span></p>
|
|
||||||
<h2>Liquid Template</h2>
|
|
||||||
<div class="content-preview">\${esc(p.content || "(template not included in preview)")}</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button class="btn btn-accept" onclick="acceptProposal()">Create Template</button>
|
|
||||||
<button class="btn btn-discard" onclick="discardProposal()">Discard</button>
|
|
||||||
</div>
|
|
||||||
\`;
|
|
||||||
};
|
|
||||||
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reviewMetadataHtml(): string {
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head><meta charset="UTF-8"><title>Review Metadata Changes</title>
|
|
||||||
<style>${baseStyles()}</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="review">
|
|
||||||
<p class="meta">Waiting for metadata data...</p>
|
|
||||||
</div>
|
|
||||||
<div id="status" class="status" style="display:none"></div>
|
|
||||||
<script type="module">
|
|
||||||
${appScript()}
|
|
||||||
window.renderReview = (data) => {
|
|
||||||
const current = data.current || {};
|
|
||||||
const proposed = data.proposed || {};
|
|
||||||
const fields = Object.keys(proposed);
|
|
||||||
let rows = fields.map(f => \`
|
|
||||||
<tr>
|
|
||||||
<td><strong>\${esc(f)}</strong></td>
|
|
||||||
<td class="diff-old">\${esc(fmt(current[f]))}</td>
|
|
||||||
<td class="diff-new">\${esc(fmt(proposed[f]))}</td>
|
|
||||||
</tr>
|
|
||||||
\`).join("");
|
|
||||||
document.getElementById("review").innerHTML = \`
|
|
||||||
<h1>Metadata Changes</h1>
|
|
||||||
<table class="diff-table">
|
|
||||||
<thead><tr><th>Field</th><th>Current</th><th>Proposed</th></tr></thead>
|
|
||||||
<tbody>\${rows}</tbody>
|
|
||||||
</table>
|
|
||||||
<div class="actions">
|
|
||||||
<button class="btn btn-accept" onclick="acceptProposal()">Apply Changes</button>
|
|
||||||
<button class="btn btn-discard" onclick="discardProposal()">Discard</button>
|
|
||||||
</div>
|
|
||||||
\`;
|
|
||||||
};
|
|
||||||
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
|
|
||||||
function fmt(v) { if (v == null) return "(empty)"; if (Array.isArray(v)) return v.join(", "); return String(v); }
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
132
src/main/engine/mcp-views/review-metadata.html
Normal file
132
src/main/engine/mcp-views/review-metadata.html
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="UTF-8"><title>Review Metadata</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; padding: 16px; color: #1a1a1a; background: #fff; line-height: 1.5; }
|
||||||
|
h1 { font-size: 1.25rem; margin-bottom: 12px; }
|
||||||
|
h2 { font-size: 1rem; margin: 12px 0 8px; color: #555; }
|
||||||
|
.meta { color: #666; font-size: 0.875rem; margin-bottom: 8px; }
|
||||||
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
|
||||||
|
.badge-draft { background: #fef3cd; color: #856404; }
|
||||||
|
.badge-kind { background: #d1ecf1; color: #0c5460; }
|
||||||
|
.content-preview { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 12px; margin: 8px 0; overflow-x: auto; white-space: pre-wrap; font-family: monospace; font-size: 0.85rem; max-height: 400px; overflow-y: auto; }
|
||||||
|
.actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||||
|
.btn { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem; font-weight: 500; }
|
||||||
|
.btn-accept { background: #28a745; color: #fff; }
|
||||||
|
.btn-accept:hover { background: #218838; }
|
||||||
|
.btn-discard { background: #dc3545; color: #fff; }
|
||||||
|
.btn-discard:hover { background: #c82333; }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.status { margin-top: 12px; padding: 8px 12px; border-radius: 6px; font-size: 0.875rem; }
|
||||||
|
.status-success { background: #d4edda; color: #155724; }
|
||||||
|
.status-error { background: #f8d7da; color: #721c24; }
|
||||||
|
.word-count { color: #888; font-size: 0.8rem; }
|
||||||
|
.diff-table { width: 100%; border-collapse: collapse; margin: 8px 0; }
|
||||||
|
.diff-table th, .diff-table td { padding: 6px 10px; border: 1px solid #dee2e6; text-align: left; font-size: 0.85rem; }
|
||||||
|
.diff-table th { background: #f1f3f5; font-weight: 600; }
|
||||||
|
.diff-old { background: #ffeef0; }
|
||||||
|
.diff-new { background: #e6ffed; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="review">
|
||||||
|
<p class="meta">Waiting for metadata...</p>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status" style="display:none"></div>
|
||||||
|
<script type="module">
|
||||||
|
import { App } from "@modelcontextprotocol/ext-apps/app-with-deps";
|
||||||
|
|
||||||
|
const app = new App({ name: "bDS Review", version: "1.0.0" });
|
||||||
|
|
||||||
|
let currentData = null;
|
||||||
|
|
||||||
|
app.ontoolresult = (result) => {
|
||||||
|
try {
|
||||||
|
const textContent = result.content?.find(c => c.type === "text");
|
||||||
|
if (textContent?.text) {
|
||||||
|
currentData = JSON.parse(textContent.text);
|
||||||
|
renderReview(currentData);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showStatus("Failed to parse tool result: " + e.message, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.acceptProposal = async () => {
|
||||||
|
if (!currentData?.proposalId) return;
|
||||||
|
setButtonsDisabled(true);
|
||||||
|
try {
|
||||||
|
const result = await app.callServerTool({
|
||||||
|
name: "accept_proposal",
|
||||||
|
arguments: { proposalId: currentData.proposalId }
|
||||||
|
});
|
||||||
|
const text = result.content?.find(c => c.type === "text")?.text;
|
||||||
|
const parsed = text ? JSON.parse(text) : {};
|
||||||
|
showStatus(parsed.success ? "Accepted!" : (parsed.message || "Failed"), parsed.success ? "success" : "error");
|
||||||
|
} catch (e) {
|
||||||
|
showStatus("Error: " + e.message, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.discardProposal = async () => {
|
||||||
|
if (!currentData?.proposalId) return;
|
||||||
|
setButtonsDisabled(true);
|
||||||
|
try {
|
||||||
|
const result = await app.callServerTool({
|
||||||
|
name: "discard_proposal",
|
||||||
|
arguments: { proposalId: currentData.proposalId }
|
||||||
|
});
|
||||||
|
const text = result.content?.find(c => c.type === "text")?.text;
|
||||||
|
const parsed = text ? JSON.parse(text) : {};
|
||||||
|
showStatus(parsed.success ? "Discarded." : (parsed.message || "Failed"), parsed.success ? "success" : "error");
|
||||||
|
} catch (e) {
|
||||||
|
showStatus("Error: " + e.message, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function setButtonsDisabled(disabled) {
|
||||||
|
document.querySelectorAll(".btn").forEach(b => b.disabled = disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(message, type) {
|
||||||
|
const el = document.getElementById("status");
|
||||||
|
if (el) {
|
||||||
|
el.textContent = message;
|
||||||
|
el.className = "status status-" + type;
|
||||||
|
el.style.display = "block";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.showStatus = showStatus;
|
||||||
|
|
||||||
|
window.renderReview = (data) => {
|
||||||
|
const current = data.current || {};
|
||||||
|
const proposed = data.proposed || {};
|
||||||
|
const fields = Object.keys(proposed);
|
||||||
|
let rows = fields.map(f => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${esc(f)}</strong></td>
|
||||||
|
<td class="diff-old">${esc(fmt(current[f]))}</td>
|
||||||
|
<td class="diff-new">${esc(fmt(proposed[f]))}</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
document.getElementById("review").innerHTML = `
|
||||||
|
<h1>Metadata Changes</h1>
|
||||||
|
<table class="diff-table">
|
||||||
|
<thead><tr><th>Field</th><th>Current</th><th>Proposed</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-accept" onclick="acceptProposal()">Apply Changes</button>
|
||||||
|
<button class="btn btn-discard" onclick="discardProposal()">Discard</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
function fmt(v) { if (v == null) return "(empty)"; if (Array.isArray(v)) return v.join(", "); return String(v); }
|
||||||
|
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
|
||||||
|
|
||||||
|
app.connect().catch(e => console.error("App connect failed:", e));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
124
src/main/engine/mcp-views/review-post.html
Normal file
124
src/main/engine/mcp-views/review-post.html
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="UTF-8"><title>Review Post</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; padding: 16px; color: #1a1a1a; background: #fff; line-height: 1.5; }
|
||||||
|
h1 { font-size: 1.25rem; margin-bottom: 12px; }
|
||||||
|
h2 { font-size: 1rem; margin: 12px 0 8px; color: #555; }
|
||||||
|
.meta { color: #666; font-size: 0.875rem; margin-bottom: 8px; }
|
||||||
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
|
||||||
|
.badge-draft { background: #fef3cd; color: #856404; }
|
||||||
|
.badge-kind { background: #d1ecf1; color: #0c5460; }
|
||||||
|
.content-preview { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 12px; margin: 8px 0; overflow-x: auto; white-space: pre-wrap; font-family: monospace; font-size: 0.85rem; max-height: 400px; overflow-y: auto; }
|
||||||
|
.actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||||
|
.btn { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem; font-weight: 500; }
|
||||||
|
.btn-accept { background: #28a745; color: #fff; }
|
||||||
|
.btn-accept:hover { background: #218838; }
|
||||||
|
.btn-discard { background: #dc3545; color: #fff; }
|
||||||
|
.btn-discard:hover { background: #c82333; }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.status { margin-top: 12px; padding: 8px 12px; border-radius: 6px; font-size: 0.875rem; }
|
||||||
|
.status-success { background: #d4edda; color: #155724; }
|
||||||
|
.status-error { background: #f8d7da; color: #721c24; }
|
||||||
|
.word-count { color: #888; font-size: 0.8rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="review">
|
||||||
|
<p class="meta">Waiting for post data...</p>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status" style="display:none"></div>
|
||||||
|
<script type="module">
|
||||||
|
import { App } from "@modelcontextprotocol/ext-apps/app-with-deps";
|
||||||
|
|
||||||
|
const app = new App({ name: "bDS Review", version: "1.0.0" });
|
||||||
|
|
||||||
|
let currentData = null;
|
||||||
|
|
||||||
|
app.ontoolresult = (result) => {
|
||||||
|
try {
|
||||||
|
const textContent = result.content?.find(c => c.type === "text");
|
||||||
|
if (textContent?.text) {
|
||||||
|
currentData = JSON.parse(textContent.text);
|
||||||
|
renderReview(currentData);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showStatus("Failed to parse tool result: " + e.message, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.acceptProposal = async () => {
|
||||||
|
if (!currentData?.proposalId) return;
|
||||||
|
setButtonsDisabled(true);
|
||||||
|
try {
|
||||||
|
const result = await app.callServerTool({
|
||||||
|
name: "accept_proposal",
|
||||||
|
arguments: { proposalId: currentData.proposalId }
|
||||||
|
});
|
||||||
|
const text = result.content?.find(c => c.type === "text")?.text;
|
||||||
|
const parsed = text ? JSON.parse(text) : {};
|
||||||
|
showStatus(parsed.success ? "Accepted!" : (parsed.message || "Failed"), parsed.success ? "success" : "error");
|
||||||
|
} catch (e) {
|
||||||
|
showStatus("Error: " + e.message, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.discardProposal = async () => {
|
||||||
|
if (!currentData?.proposalId) return;
|
||||||
|
setButtonsDisabled(true);
|
||||||
|
try {
|
||||||
|
const result = await app.callServerTool({
|
||||||
|
name: "discard_proposal",
|
||||||
|
arguments: { proposalId: currentData.proposalId }
|
||||||
|
});
|
||||||
|
const text = result.content?.find(c => c.type === "text")?.text;
|
||||||
|
const parsed = text ? JSON.parse(text) : {};
|
||||||
|
showStatus(parsed.success ? "Discarded." : (parsed.message || "Failed"), parsed.success ? "success" : "error");
|
||||||
|
} catch (e) {
|
||||||
|
showStatus("Error: " + e.message, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function setButtonsDisabled(disabled) {
|
||||||
|
document.querySelectorAll(".btn").forEach(b => b.disabled = disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(message, type) {
|
||||||
|
const el = document.getElementById("status");
|
||||||
|
if (el) {
|
||||||
|
el.textContent = message;
|
||||||
|
el.className = "status status-" + type;
|
||||||
|
el.style.display = "block";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.showStatus = showStatus;
|
||||||
|
window.renderReview = window.renderReview || (() => {});
|
||||||
|
|
||||||
|
window.renderReview = (data) => {
|
||||||
|
const post = data.post || {};
|
||||||
|
const wc = (post.content || "").split(/\s+/).filter(Boolean).length;
|
||||||
|
document.getElementById("review").innerHTML = `
|
||||||
|
<h1>${esc(post.title || "Untitled")}</h1>
|
||||||
|
<p class="meta">
|
||||||
|
<span class="badge badge-draft">Draft</span>
|
||||||
|
<span class="word-count">${wc} words</span>
|
||||||
|
</p>
|
||||||
|
${post.categories?.length ? '<p class="meta">Categories: ' + post.categories.map(c => esc(c)).join(", ") + '</p>' : ''}
|
||||||
|
${post.tags?.length ? '<p class="meta">Tags: ' + post.tags.map(t => esc(t)).join(", ") + '</p>' : ''}
|
||||||
|
${post.excerpt ? '<h2>Excerpt</h2><p>' + esc(post.excerpt) + '</p>' : ''}
|
||||||
|
<h2>Content</h2>
|
||||||
|
<div class="content-preview">${esc(post.content || "")}</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-accept" onclick="acceptProposal()">Publish</button>
|
||||||
|
<button class="btn btn-discard" onclick="discardProposal()">Discard Draft</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
|
||||||
|
|
||||||
|
app.connect().catch(e => console.error("App connect failed:", e));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
116
src/main/engine/mcp-views/review-script.html
Normal file
116
src/main/engine/mcp-views/review-script.html
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="UTF-8"><title>Review Script</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; padding: 16px; color: #1a1a1a; background: #fff; line-height: 1.5; }
|
||||||
|
h1 { font-size: 1.25rem; margin-bottom: 12px; }
|
||||||
|
h2 { font-size: 1rem; margin: 12px 0 8px; color: #555; }
|
||||||
|
.meta { color: #666; font-size: 0.875rem; margin-bottom: 8px; }
|
||||||
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
|
||||||
|
.badge-draft { background: #fef3cd; color: #856404; }
|
||||||
|
.badge-kind { background: #d1ecf1; color: #0c5460; }
|
||||||
|
.content-preview { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 12px; margin: 8px 0; overflow-x: auto; white-space: pre-wrap; font-family: monospace; font-size: 0.85rem; max-height: 400px; overflow-y: auto; }
|
||||||
|
.actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||||
|
.btn { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem; font-weight: 500; }
|
||||||
|
.btn-accept { background: #28a745; color: #fff; }
|
||||||
|
.btn-accept:hover { background: #218838; }
|
||||||
|
.btn-discard { background: #dc3545; color: #fff; }
|
||||||
|
.btn-discard:hover { background: #c82333; }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.status { margin-top: 12px; padding: 8px 12px; border-radius: 6px; font-size: 0.875rem; }
|
||||||
|
.status-success { background: #d4edda; color: #155724; }
|
||||||
|
.status-error { background: #f8d7da; color: #721c24; }
|
||||||
|
.word-count { color: #888; font-size: 0.8rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="review">
|
||||||
|
<p class="meta">Waiting for script data...</p>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status" style="display:none"></div>
|
||||||
|
<script type="module">
|
||||||
|
import { App } from "@modelcontextprotocol/ext-apps/app-with-deps";
|
||||||
|
|
||||||
|
const app = new App({ name: "bDS Review", version: "1.0.0" });
|
||||||
|
|
||||||
|
let currentData = null;
|
||||||
|
|
||||||
|
app.ontoolresult = (result) => {
|
||||||
|
try {
|
||||||
|
const textContent = result.content?.find(c => c.type === "text");
|
||||||
|
if (textContent?.text) {
|
||||||
|
currentData = JSON.parse(textContent.text);
|
||||||
|
renderReview(currentData);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showStatus("Failed to parse tool result: " + e.message, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.acceptProposal = async () => {
|
||||||
|
if (!currentData?.proposalId) return;
|
||||||
|
setButtonsDisabled(true);
|
||||||
|
try {
|
||||||
|
const result = await app.callServerTool({
|
||||||
|
name: "accept_proposal",
|
||||||
|
arguments: { proposalId: currentData.proposalId }
|
||||||
|
});
|
||||||
|
const text = result.content?.find(c => c.type === "text")?.text;
|
||||||
|
const parsed = text ? JSON.parse(text) : {};
|
||||||
|
showStatus(parsed.success ? "Accepted!" : (parsed.message || "Failed"), parsed.success ? "success" : "error");
|
||||||
|
} catch (e) {
|
||||||
|
showStatus("Error: " + e.message, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.discardProposal = async () => {
|
||||||
|
if (!currentData?.proposalId) return;
|
||||||
|
setButtonsDisabled(true);
|
||||||
|
try {
|
||||||
|
const result = await app.callServerTool({
|
||||||
|
name: "discard_proposal",
|
||||||
|
arguments: { proposalId: currentData.proposalId }
|
||||||
|
});
|
||||||
|
const text = result.content?.find(c => c.type === "text")?.text;
|
||||||
|
const parsed = text ? JSON.parse(text) : {};
|
||||||
|
showStatus(parsed.success ? "Discarded." : (parsed.message || "Failed"), parsed.success ? "success" : "error");
|
||||||
|
} catch (e) {
|
||||||
|
showStatus("Error: " + e.message, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function setButtonsDisabled(disabled) {
|
||||||
|
document.querySelectorAll(".btn").forEach(b => b.disabled = disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(message, type) {
|
||||||
|
const el = document.getElementById("status");
|
||||||
|
if (el) {
|
||||||
|
el.textContent = message;
|
||||||
|
el.className = "status status-" + type;
|
||||||
|
el.style.display = "block";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.showStatus = showStatus;
|
||||||
|
|
||||||
|
window.renderReview = (data) => {
|
||||||
|
const p = data.preview || data;
|
||||||
|
document.getElementById("review").innerHTML = `
|
||||||
|
<h1>${esc(p.title || "Untitled Script")}</h1>
|
||||||
|
<p class="meta"><span class="badge badge-kind">${esc(p.kind || "script")}</span></p>
|
||||||
|
<h2>Python Code</h2>
|
||||||
|
<div class="content-preview">${esc(p.content || "(code not included in preview)")}</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-accept" onclick="acceptProposal()">Create Script</button>
|
||||||
|
<button class="btn btn-discard" onclick="discardProposal()">Discard</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
|
||||||
|
|
||||||
|
app.connect().catch(e => console.error("App connect failed:", e));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
116
src/main/engine/mcp-views/review-template.html
Normal file
116
src/main/engine/mcp-views/review-template.html
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="UTF-8"><title>Review Template</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; padding: 16px; color: #1a1a1a; background: #fff; line-height: 1.5; }
|
||||||
|
h1 { font-size: 1.25rem; margin-bottom: 12px; }
|
||||||
|
h2 { font-size: 1rem; margin: 12px 0 8px; color: #555; }
|
||||||
|
.meta { color: #666; font-size: 0.875rem; margin-bottom: 8px; }
|
||||||
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
|
||||||
|
.badge-draft { background: #fef3cd; color: #856404; }
|
||||||
|
.badge-kind { background: #d1ecf1; color: #0c5460; }
|
||||||
|
.content-preview { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 12px; margin: 8px 0; overflow-x: auto; white-space: pre-wrap; font-family: monospace; font-size: 0.85rem; max-height: 400px; overflow-y: auto; }
|
||||||
|
.actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||||
|
.btn { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem; font-weight: 500; }
|
||||||
|
.btn-accept { background: #28a745; color: #fff; }
|
||||||
|
.btn-accept:hover { background: #218838; }
|
||||||
|
.btn-discard { background: #dc3545; color: #fff; }
|
||||||
|
.btn-discard:hover { background: #c82333; }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.status { margin-top: 12px; padding: 8px 12px; border-radius: 6px; font-size: 0.875rem; }
|
||||||
|
.status-success { background: #d4edda; color: #155724; }
|
||||||
|
.status-error { background: #f8d7da; color: #721c24; }
|
||||||
|
.word-count { color: #888; font-size: 0.8rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="review">
|
||||||
|
<p class="meta">Waiting for template data...</p>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status" style="display:none"></div>
|
||||||
|
<script type="module">
|
||||||
|
import { App } from "@modelcontextprotocol/ext-apps/app-with-deps";
|
||||||
|
|
||||||
|
const app = new App({ name: "bDS Review", version: "1.0.0" });
|
||||||
|
|
||||||
|
let currentData = null;
|
||||||
|
|
||||||
|
app.ontoolresult = (result) => {
|
||||||
|
try {
|
||||||
|
const textContent = result.content?.find(c => c.type === "text");
|
||||||
|
if (textContent?.text) {
|
||||||
|
currentData = JSON.parse(textContent.text);
|
||||||
|
renderReview(currentData);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showStatus("Failed to parse tool result: " + e.message, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.acceptProposal = async () => {
|
||||||
|
if (!currentData?.proposalId) return;
|
||||||
|
setButtonsDisabled(true);
|
||||||
|
try {
|
||||||
|
const result = await app.callServerTool({
|
||||||
|
name: "accept_proposal",
|
||||||
|
arguments: { proposalId: currentData.proposalId }
|
||||||
|
});
|
||||||
|
const text = result.content?.find(c => c.type === "text")?.text;
|
||||||
|
const parsed = text ? JSON.parse(text) : {};
|
||||||
|
showStatus(parsed.success ? "Accepted!" : (parsed.message || "Failed"), parsed.success ? "success" : "error");
|
||||||
|
} catch (e) {
|
||||||
|
showStatus("Error: " + e.message, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.discardProposal = async () => {
|
||||||
|
if (!currentData?.proposalId) return;
|
||||||
|
setButtonsDisabled(true);
|
||||||
|
try {
|
||||||
|
const result = await app.callServerTool({
|
||||||
|
name: "discard_proposal",
|
||||||
|
arguments: { proposalId: currentData.proposalId }
|
||||||
|
});
|
||||||
|
const text = result.content?.find(c => c.type === "text")?.text;
|
||||||
|
const parsed = text ? JSON.parse(text) : {};
|
||||||
|
showStatus(parsed.success ? "Discarded." : (parsed.message || "Failed"), parsed.success ? "success" : "error");
|
||||||
|
} catch (e) {
|
||||||
|
showStatus("Error: " + e.message, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function setButtonsDisabled(disabled) {
|
||||||
|
document.querySelectorAll(".btn").forEach(b => b.disabled = disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(message, type) {
|
||||||
|
const el = document.getElementById("status");
|
||||||
|
if (el) {
|
||||||
|
el.textContent = message;
|
||||||
|
el.className = "status status-" + type;
|
||||||
|
el.style.display = "block";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.showStatus = showStatus;
|
||||||
|
|
||||||
|
window.renderReview = (data) => {
|
||||||
|
const p = data.preview || data;
|
||||||
|
document.getElementById("review").innerHTML = `
|
||||||
|
<h1>${esc(p.title || "Untitled Template")}</h1>
|
||||||
|
<p class="meta"><span class="badge badge-kind">${esc(p.kind || "template")}</span></p>
|
||||||
|
<h2>Liquid Template</h2>
|
||||||
|
<div class="content-preview">${esc(p.content || "(template not included in preview)")}</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-accept" onclick="acceptProposal()">Create Template</button>
|
||||||
|
<button class="btn btn-discard" onclick="discardProposal()">Discard</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
|
||||||
|
|
||||||
|
app.connect().catch(e => console.error("App connect failed:", e));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -125,6 +125,16 @@ function runWebContentsMenuAction(sender: any, action: AppMenuAction): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildMcpUrl(): string {
|
||||||
|
try {
|
||||||
|
const { getMCPServer } = require('../engine/MCPServer');
|
||||||
|
const port = getMCPServer().getPort() ?? 4124;
|
||||||
|
return `http://127.0.0.1:${port}/mcp`;
|
||||||
|
} catch {
|
||||||
|
return 'http://127.0.0.1:4124/mcp';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function registerIpcHandlers(): void {
|
export function registerIpcHandlers(): void {
|
||||||
// ============ Git Handlers ============
|
// ============ Git Handlers ============
|
||||||
|
|
||||||
@@ -1562,6 +1572,47 @@ export function registerIpcHandlers(): void {
|
|||||||
registerBlogHandlers(safeHandle);
|
registerBlogHandlers(safeHandle);
|
||||||
registerPublishHandlers(safeHandle);
|
registerPublishHandlers(safeHandle);
|
||||||
|
|
||||||
|
// ============ MCP Config Handlers ============
|
||||||
|
|
||||||
|
safeHandle('mcp:getAgents', async () => {
|
||||||
|
const { MCPAgentConfigEngine } = await import('../engine/MCPAgentConfigEngine');
|
||||||
|
const engine = new MCPAgentConfigEngine({
|
||||||
|
homeDir: require('os').homedir(),
|
||||||
|
platform: process.platform,
|
||||||
|
mcpUrl: buildMcpUrl(),
|
||||||
|
});
|
||||||
|
return engine.getAgents();
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('mcp:addToAgentConfig', async (_event: unknown, agentId: string) => {
|
||||||
|
const { MCPAgentConfigEngine } = await import('../engine/MCPAgentConfigEngine');
|
||||||
|
const engine = new MCPAgentConfigEngine({
|
||||||
|
homeDir: require('os').homedir(),
|
||||||
|
platform: process.platform,
|
||||||
|
mcpUrl: buildMcpUrl(),
|
||||||
|
});
|
||||||
|
return engine.addToConfig(agentId as import('../engine/MCPAgentConfigEngine').MCPAgentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('mcp:isConfigured', async (_event: unknown, agentId: string) => {
|
||||||
|
const { MCPAgentConfigEngine } = await import('../engine/MCPAgentConfigEngine');
|
||||||
|
const engine = new MCPAgentConfigEngine({
|
||||||
|
homeDir: require('os').homedir(),
|
||||||
|
platform: process.platform,
|
||||||
|
mcpUrl: buildMcpUrl(),
|
||||||
|
});
|
||||||
|
return engine.isConfigured(agentId as import('../engine/MCPAgentConfigEngine').MCPAgentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('mcp:getPort', async () => {
|
||||||
|
try {
|
||||||
|
const { getMCPServer } = await import('../engine/MCPServer');
|
||||||
|
return getMCPServer().getPort();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============ Event Forwarding ============
|
// ============ Event Forwarding ============
|
||||||
|
|
||||||
// Forward engine events to renderer
|
// Forward engine events to renderer
|
||||||
|
|||||||
@@ -382,6 +382,13 @@ export const electronAPI: ElectronAPI = {
|
|||||||
once: (channel: string, callback: (...args: unknown[]) => void) => {
|
once: (channel: string, callback: (...args: unknown[]) => void) => {
|
||||||
ipcRenderer.once(channel, (_event, ...args) => callback(...args));
|
ipcRenderer.once(channel, (_event, ...args) => callback(...args));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
mcp: {
|
||||||
|
getAgents: () => ipcRenderer.invoke('mcp:getAgents'),
|
||||||
|
addToAgentConfig: (agentId: string) => ipcRenderer.invoke('mcp:addToAgentConfig', agentId),
|
||||||
|
isConfigured: (agentId: string) => ipcRenderer.invoke('mcp:isConfigured', agentId),
|
||||||
|
getPort: () => ipcRenderer.invoke('mcp:getPort'),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
||||||
|
|||||||
@@ -836,5 +836,11 @@ export interface ElectronAPI {
|
|||||||
};
|
};
|
||||||
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
|
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
|
||||||
once: (channel: string, callback: (...args: unknown[]) => void) => void;
|
once: (channel: string, callback: (...args: unknown[]) => void) => void;
|
||||||
|
mcp: {
|
||||||
|
getAgents: () => Promise<Array<{ id: string; label: string }>>;
|
||||||
|
addToAgentConfig: (agentId: string) => Promise<{ success: boolean; configPath: string; error?: string }>;
|
||||||
|
isConfigured: (agentId: string) => Promise<boolean>;
|
||||||
|
getPort: () => Promise<number | null>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1674,7 +1674,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<span className="timeline-bar-count">{entry.count}</span>
|
<span className="timeline-bar-count">{entry.count}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="timeline-bar-label">
|
<div className="timeline-bar-label">
|
||||||
<span className="timeline-bar-label-month">{monthFormatter.format(new Date(entry.year, entry.month, 1))}</span>
|
<span className="timeline-bar-label-month">{monthFormatter.format(new Date(entry.year, entry.month - 1, 1))}</span>
|
||||||
<span className="timeline-bar-label-year">{entry.year}</span>
|
<span className="timeline-bar-label-year">{entry.year}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import './SettingsView.css';
|
import './SettingsView.css';
|
||||||
|
|
||||||
// Export category IDs for sidebar navigation
|
// Export category IDs for sidebar navigation
|
||||||
export type SettingsCategory = 'project' | 'editor' | 'content' | 'ai' | 'technology' | 'publishing' | 'data';
|
export type SettingsCategory = 'project' | 'editor' | 'content' | 'ai' | 'technology' | 'publishing' | 'data' | 'mcp';
|
||||||
|
|
||||||
// Scroll to a settings section by category ID
|
// Scroll to a settings section by category ID
|
||||||
export const scrollToSettingsSection = (category: SettingsCategory) => {
|
export const scrollToSettingsSection = (category: SettingsCategory) => {
|
||||||
@@ -122,6 +122,62 @@ const SettingSection: React.FC<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Small component that shows the MCP server port or "Not running". */
|
||||||
|
const MCPStatusBadge: React.FC = () => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [port, setPort] = React.useState<number | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
window.electronAPI?.mcp?.getPort().then(setPort).catch(() => setPort(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`badge ${port ? 'badge-success' : 'badge-secondary'}`}>
|
||||||
|
{port ? t('settings.mcp.portRunning', { port: String(port) }) : t('settings.mcp.portStopped')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Button to add bDS MCP server to an agent's config. Shows "Configured" if already present. */
|
||||||
|
const MCPAgentButton: React.FC<{ agentId: string; agentLabel: string }> = ({ agentId, agentLabel }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [configured, setConfigured] = React.useState(false);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
window.electronAPI?.mcp?.isConfigured(agentId).then(setConfigured).catch(() => setConfigured(false));
|
||||||
|
}, [agentId]);
|
||||||
|
|
||||||
|
if (configured) {
|
||||||
|
return <span className="badge badge-success">{t('settings.mcp.alreadyConfigured')}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI?.mcp?.addToAgentConfig(agentId);
|
||||||
|
if (result?.success) {
|
||||||
|
showToast.success(t('settings.toast.mcpConfigSuccess', { agent: agentLabel }));
|
||||||
|
setConfigured(true);
|
||||||
|
} else {
|
||||||
|
showToast.error(t('settings.toast.mcpConfigFailed', { agent: agentLabel, error: result?.error ?? 'Unknown error' }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast.error(t('settings.toast.mcpConfigFailed', { agent: agentLabel, error: 'Unexpected error' }));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.mcp.addToAgent', { agent: agentLabel })}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const SettingsView: React.FC = () => {
|
export const SettingsView: React.FC = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const {
|
const {
|
||||||
@@ -417,6 +473,7 @@ export const SettingsView: React.FC = () => {
|
|||||||
const technologyKeywords = ['technology', 'python', 'runtime', 'worker', 'webworker', 'main thread', 'execution'];
|
const technologyKeywords = ['technology', 'python', 'runtime', 'worker', 'webworker', 'main thread', 'execution'];
|
||||||
const publishingKeywords = ['publishing', 'ssh', 'deploy', 'server', 'host', 'upload', 'scp', 'rsync'];
|
const publishingKeywords = ['publishing', 'ssh', 'deploy', 'server', 'host', 'upload', 'scp', 'rsync'];
|
||||||
const dataKeywords = ['data', 'database', 'rebuild', 'maintenance', 'posts', 'media', 'scripts', 'links', 'folder', 'filesystem'];
|
const dataKeywords = ['data', 'database', 'rebuild', 'maintenance', 'posts', 'media', 'scripts', 'links', 'folder', 'filesystem'];
|
||||||
|
const mcpKeywords = ['mcp', 'server', 'agent', 'claude', 'copilot', 'gemini', 'opencode', 'model context protocol', 'coding', 'configuration'];
|
||||||
|
|
||||||
const renderProjectSettings = () => (
|
const renderProjectSettings = () => (
|
||||||
<SettingSection
|
<SettingSection
|
||||||
@@ -1197,6 +1254,43 @@ export const SettingsView: React.FC = () => {
|
|||||||
</SettingSection>
|
</SettingSection>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderMCPSettings = () => {
|
||||||
|
const agents = [
|
||||||
|
{ id: 'claude-code', label: 'Claude Code' },
|
||||||
|
{ id: 'github-copilot', label: 'GitHub Copilot' },
|
||||||
|
{ id: 'gemini-cli', label: 'Gemini CLI' },
|
||||||
|
{ id: 'opencode', label: 'OpenCode' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingSection
|
||||||
|
id="settings-section-mcp"
|
||||||
|
title={t('settings.mcp.title')}
|
||||||
|
description={t('settings.mcp.description')}
|
||||||
|
hidden={!sectionHasMatches(mcpKeywords)}
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
id="mcp-status"
|
||||||
|
label={t('settings.mcp.statusLabel')}
|
||||||
|
description={t('settings.mcp.statusDescription')}
|
||||||
|
>
|
||||||
|
<MCPStatusBadge />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<SettingRow
|
||||||
|
key={agent.id}
|
||||||
|
id={`mcp-agent-${agent.id}`}
|
||||||
|
label={t('settings.mcp.addToAgent', { agent: agent.label })}
|
||||||
|
description=""
|
||||||
|
>
|
||||||
|
<MCPAgentButton agentId={agent.id} agentLabel={agent.label} />
|
||||||
|
</SettingRow>
|
||||||
|
))}
|
||||||
|
</SettingSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderDataSettings = () => (
|
const renderDataSettings = () => (
|
||||||
<>
|
<>
|
||||||
<SettingSection
|
<SettingSection
|
||||||
@@ -1392,7 +1486,8 @@ export const SettingsView: React.FC = () => {
|
|||||||
sectionHasMatches(aiKeywords) ||
|
sectionHasMatches(aiKeywords) ||
|
||||||
sectionHasMatches(technologyKeywords) ||
|
sectionHasMatches(technologyKeywords) ||
|
||||||
sectionHasMatches(publishingKeywords) ||
|
sectionHasMatches(publishingKeywords) ||
|
||||||
sectionHasMatches(dataKeywords);
|
sectionHasMatches(dataKeywords) ||
|
||||||
|
sectionHasMatches(mcpKeywords);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-view">
|
<div className="settings-view">
|
||||||
@@ -1429,6 +1524,7 @@ export const SettingsView: React.FC = () => {
|
|||||||
{renderTechnologySettings()}
|
{renderTechnologySettings()}
|
||||||
{renderPublishingSettings()}
|
{renderPublishingSettings()}
|
||||||
{renderDataSettings()}
|
{renderDataSettings()}
|
||||||
|
{renderMCPSettings()}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="settings-no-results">
|
<div className="settings-no-results">
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ const CalendarView: React.FC<CalendarViewProps> = ({ onDateSelect, selectedYear,
|
|||||||
onDateSelect(year, month);
|
onDateSelect(year, month);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="month-label">{MONTH_NAMES[month]}</span>
|
<span className="month-label">{MONTH_NAMES[month - 1]}</span>
|
||||||
<span className="month-count">{count}</span>
|
<span className="month-count">{count}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -374,7 +374,7 @@ const MediaCalendarView: React.FC<MediaCalendarViewProps> = ({ onDateSelect, sel
|
|||||||
onDateSelect(year, month);
|
onDateSelect(year, month);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="month-label">{MONTH_NAMES[month]}</span>
|
<span className="month-label">{MONTH_NAMES[month - 1]}</span>
|
||||||
<span className="month-count">{count}</span>
|
<span className="month-count">{count}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -1261,7 +1261,7 @@ const SettingsNav: React.FC = () => {
|
|||||||
const { tabs, activeTabId, openTab } = useAppStore();
|
const { tabs, activeTabId, openTab } = useAppStore();
|
||||||
const [activeSection, setActiveSection] = useState<SettingsCategory | null>(() => {
|
const [activeSection, setActiveSection] = useState<SettingsCategory | null>(() => {
|
||||||
const persisted = getPersistedSidebarSection('settings');
|
const persisted = getPersistedSidebarSection('settings');
|
||||||
if (persisted === 'project' || persisted === 'editor' || persisted === 'content' || persisted === 'ai' || persisted === 'technology' || persisted === 'publishing' || persisted === 'data') {
|
if (persisted === 'project' || persisted === 'editor' || persisted === 'content' || persisted === 'ai' || persisted === 'technology' || persisted === 'publishing' || persisted === 'data' || persisted === 'mcp') {
|
||||||
return persisted;
|
return persisted;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -1343,6 +1343,13 @@ const SettingsNav: React.FC = () => {
|
|||||||
<span className="settings-nav-entry-icon">🗄️</span>
|
<span className="settings-nav-entry-icon">🗄️</span>
|
||||||
<span>{t('sidebar.nav.data')}</span>
|
<span>{t('sidebar.nav.data')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`settings-nav-entry ${activeSection === 'mcp' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleNavClick('mcp')}
|
||||||
|
>
|
||||||
|
<span className="settings-nav-entry-icon">🔌</span>
|
||||||
|
<span>{t('sidebar.nav.mcp')}</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`settings-nav-entry ${isStyleTabActive ? 'active' : ''}`}
|
className={`settings-nav-entry ${isStyleTabActive ? 'active' : ''}`}
|
||||||
onClick={handleStyleClick}
|
onClick={handleStyleClick}
|
||||||
|
|||||||
@@ -975,5 +975,21 @@
|
|||||||
"importAnalysis.macroUses": "{count} Verwendungen",
|
"importAnalysis.macroUses": "{count} Verwendungen",
|
||||||
"importAnalysis.usedIn": "Verwendet in: {items}{more}",
|
"importAnalysis.usedIn": "Verwendet in: {items}{more}",
|
||||||
"importAnalysis.moreSuffix": ", +{count} weitere",
|
"importAnalysis.moreSuffix": ", +{count} weitere",
|
||||||
"importAnalysis.noParameters": "(keine Parameter)"
|
"importAnalysis.noParameters": "(keine Parameter)",
|
||||||
|
|
||||||
|
"sidebar.nav.mcp": "MCP-Server",
|
||||||
|
|
||||||
|
"settings.mcp.title": "MCP-Server",
|
||||||
|
"settings.mcp.description": "Konfigurieren Sie den Model Context Protocol Server, der KI-Programmieragenten die Interaktion mit Ihrem Blog ermöglicht.",
|
||||||
|
"settings.mcp.statusLabel": "Serverstatus",
|
||||||
|
"settings.mcp.statusDescription": "Aktueller Status des MCP-Servers.",
|
||||||
|
"settings.mcp.portRunning": "Läuft auf Port {port}",
|
||||||
|
"settings.mcp.portStopped": "Nicht gestartet",
|
||||||
|
"settings.mcp.agentsTitle": "Agenten-Konfiguration",
|
||||||
|
"settings.mcp.agentsDescription": "Fügen Sie den bDS MCP-Server zur Konfiguration Ihres Programmieragenten hinzu. Vorhandene Einstellungen bleiben erhalten.",
|
||||||
|
"settings.mcp.addToAgent": "Zu {agent} hinzufügen",
|
||||||
|
"settings.mcp.alreadyConfigured": "Konfiguriert",
|
||||||
|
"settings.toast.mcpConfigSuccess": "bDS MCP-Server zur {agent}-Konfiguration hinzugefügt",
|
||||||
|
"settings.toast.mcpConfigFailed": "Konfiguration von {agent} fehlgeschlagen: {error}",
|
||||||
|
"settings.toast.mcpConfigPath": "Konfiguration geschrieben nach {path}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -975,5 +975,21 @@
|
|||||||
"importAnalysis.macroUses": "{count} uses",
|
"importAnalysis.macroUses": "{count} uses",
|
||||||
"importAnalysis.usedIn": "Used in: {items}{more}",
|
"importAnalysis.usedIn": "Used in: {items}{more}",
|
||||||
"importAnalysis.moreSuffix": ", +{count} more",
|
"importAnalysis.moreSuffix": ", +{count} more",
|
||||||
"importAnalysis.noParameters": "(no parameters)"
|
"importAnalysis.noParameters": "(no parameters)",
|
||||||
|
|
||||||
|
"sidebar.nav.mcp": "MCP Server",
|
||||||
|
|
||||||
|
"settings.mcp.title": "MCP Server",
|
||||||
|
"settings.mcp.description": "Configure the Model Context Protocol server that allows AI coding agents to interact with your blog.",
|
||||||
|
"settings.mcp.statusLabel": "Server Status",
|
||||||
|
"settings.mcp.statusDescription": "Current status of the MCP server.",
|
||||||
|
"settings.mcp.portRunning": "Running on port {port}",
|
||||||
|
"settings.mcp.portStopped": "Not running",
|
||||||
|
"settings.mcp.agentsTitle": "Agent Configuration",
|
||||||
|
"settings.mcp.agentsDescription": "Add the bDS MCP server to your coding agent's configuration. Existing settings are preserved.",
|
||||||
|
"settings.mcp.addToAgent": "Add to {agent}",
|
||||||
|
"settings.mcp.alreadyConfigured": "Configured",
|
||||||
|
"settings.toast.mcpConfigSuccess": "bDS MCP server added to {agent} configuration",
|
||||||
|
"settings.toast.mcpConfigFailed": "Failed to configure {agent}: {error}",
|
||||||
|
"settings.toast.mcpConfigPath": "Config written to {path}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -975,5 +975,21 @@
|
|||||||
"importAnalysis.macroUses": "{count} usos",
|
"importAnalysis.macroUses": "{count} usos",
|
||||||
"importAnalysis.usedIn": "Usado en: {items}{more}",
|
"importAnalysis.usedIn": "Usado en: {items}{more}",
|
||||||
"importAnalysis.moreSuffix": ", +{count} más",
|
"importAnalysis.moreSuffix": ", +{count} más",
|
||||||
"importAnalysis.noParameters": "(sin parámetros)"
|
"importAnalysis.noParameters": "(sin parámetros)",
|
||||||
|
|
||||||
|
"sidebar.nav.mcp": "Servidor MCP",
|
||||||
|
|
||||||
|
"settings.mcp.title": "Servidor MCP",
|
||||||
|
"settings.mcp.description": "Configure el servidor Model Context Protocol que permite a los agentes de programación IA interactuar con su blog.",
|
||||||
|
"settings.mcp.statusLabel": "Estado del servidor",
|
||||||
|
"settings.mcp.statusDescription": "Estado actual del servidor MCP.",
|
||||||
|
"settings.mcp.portRunning": "Ejecutándose en el puerto {port}",
|
||||||
|
"settings.mcp.portStopped": "No está en ejecución",
|
||||||
|
"settings.mcp.agentsTitle": "Configuración de agentes",
|
||||||
|
"settings.mcp.agentsDescription": "Añada el servidor MCP de bDS a la configuración de su agente de programación. Las configuraciones existentes se conservan.",
|
||||||
|
"settings.mcp.addToAgent": "Añadir a {agent}",
|
||||||
|
"settings.mcp.alreadyConfigured": "Configurado",
|
||||||
|
"settings.toast.mcpConfigSuccess": "Servidor MCP de bDS añadido a la configuración de {agent}",
|
||||||
|
"settings.toast.mcpConfigFailed": "Error al configurar {agent}: {error}",
|
||||||
|
"settings.toast.mcpConfigPath": "Configuración escrita en {path}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -973,5 +973,21 @@
|
|||||||
"importAnalysis.macroUses": "{count} utilisations",
|
"importAnalysis.macroUses": "{count} utilisations",
|
||||||
"importAnalysis.usedIn": "Utilisé dans : {items}{more}",
|
"importAnalysis.usedIn": "Utilisé dans : {items}{more}",
|
||||||
"importAnalysis.moreSuffix": ", +{count} de plus",
|
"importAnalysis.moreSuffix": ", +{count} de plus",
|
||||||
"importAnalysis.noParameters": "(aucun paramètre)"
|
"importAnalysis.noParameters": "(aucun paramètre)",
|
||||||
|
|
||||||
|
"sidebar.nav.mcp": "Serveur MCP",
|
||||||
|
|
||||||
|
"settings.mcp.title": "Serveur MCP",
|
||||||
|
"settings.mcp.description": "Configurez le serveur Model Context Protocol qui permet aux agents de programmation IA d'interagir avec votre blog.",
|
||||||
|
"settings.mcp.statusLabel": "État du serveur",
|
||||||
|
"settings.mcp.statusDescription": "État actuel du serveur MCP.",
|
||||||
|
"settings.mcp.portRunning": "En cours d'exécution sur le port {port}",
|
||||||
|
"settings.mcp.portStopped": "Non démarré",
|
||||||
|
"settings.mcp.agentsTitle": "Configuration des agents",
|
||||||
|
"settings.mcp.agentsDescription": "Ajoutez le serveur MCP bDS à la configuration de votre agent de programmation. Les paramètres existants sont préservés.",
|
||||||
|
"settings.mcp.addToAgent": "Ajouter à {agent}",
|
||||||
|
"settings.mcp.alreadyConfigured": "Configuré",
|
||||||
|
"settings.toast.mcpConfigSuccess": "Serveur MCP bDS ajouté à la configuration de {agent}",
|
||||||
|
"settings.toast.mcpConfigFailed": "Échec de la configuration de {agent}: {error}",
|
||||||
|
"settings.toast.mcpConfigPath": "Configuration écrite dans {path}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -973,5 +973,21 @@
|
|||||||
"importAnalysis.macroUses": "{count} utilizzi",
|
"importAnalysis.macroUses": "{count} utilizzi",
|
||||||
"importAnalysis.usedIn": "Usato in: {items}{more}",
|
"importAnalysis.usedIn": "Usato in: {items}{more}",
|
||||||
"importAnalysis.moreSuffix": ", +{count} altri",
|
"importAnalysis.moreSuffix": ", +{count} altri",
|
||||||
"importAnalysis.noParameters": "(nessun parametro)"
|
"importAnalysis.noParameters": "(nessun parametro)",
|
||||||
|
|
||||||
|
"sidebar.nav.mcp": "Server MCP",
|
||||||
|
|
||||||
|
"settings.mcp.title": "Server MCP",
|
||||||
|
"settings.mcp.description": "Configura il server Model Context Protocol che permette agli agenti di programmazione IA di interagire con il tuo blog.",
|
||||||
|
"settings.mcp.statusLabel": "Stato del server",
|
||||||
|
"settings.mcp.statusDescription": "Stato attuale del server MCP.",
|
||||||
|
"settings.mcp.portRunning": "In esecuzione sulla porta {port}",
|
||||||
|
"settings.mcp.portStopped": "Non in esecuzione",
|
||||||
|
"settings.mcp.agentsTitle": "Configurazione agenti",
|
||||||
|
"settings.mcp.agentsDescription": "Aggiungi il server MCP bDS alla configurazione del tuo agente di programmazione. Le impostazioni esistenti vengono preservate.",
|
||||||
|
"settings.mcp.addToAgent": "Aggiungi a {agent}",
|
||||||
|
"settings.mcp.alreadyConfigured": "Configurato",
|
||||||
|
"settings.toast.mcpConfigSuccess": "Server MCP bDS aggiunto alla configurazione di {agent}",
|
||||||
|
"settings.toast.mcpConfigFailed": "Configurazione di {agent} non riuscita: {error}",
|
||||||
|
"settings.toast.mcpConfigPath": "Configurazione scritta in {path}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ describe('GenerationRouteRendererFactory', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof filter.month === 'number') {
|
if (typeof filter.month === 'number') {
|
||||||
filtered = filtered.filter((post) => post.createdAt.getMonth() === filter.month);
|
filtered = filtered.filter((post) => post.createdAt.getMonth() === filter.month - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.startDate) {
|
if (filter.startDate) {
|
||||||
|
|||||||
339
tests/engine/MCPConfigEngine.test.ts
Normal file
339
tests/engine/MCPConfigEngine.test.ts
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { MCPAgentConfigEngine, type MCPAgentId, type AgentConfigResult } from '../../src/main/engine/MCPAgentConfigEngine';
|
||||||
|
|
||||||
|
// Mock fs and os
|
||||||
|
const mockReadFileSync = vi.fn();
|
||||||
|
const mockWriteFileSync = vi.fn();
|
||||||
|
const mockExistsSync = vi.fn();
|
||||||
|
const mockMkdirSync = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('fs', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('fs')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
default: {
|
||||||
|
...actual,
|
||||||
|
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
|
||||||
|
writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
|
||||||
|
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
||||||
|
mkdirSync: (...args: unknown[]) => mockMkdirSync(...args),
|
||||||
|
},
|
||||||
|
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
|
||||||
|
writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
|
||||||
|
existsSync: (...args: unknown[]) => mockExistsSync(...args),
|
||||||
|
mkdirSync: (...args: unknown[]) => mockMkdirSync(...args),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MCPAgentConfigEngine', () => {
|
||||||
|
let engine: MCPAgentConfigEngine;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
engine = new MCPAgentConfigEngine({
|
||||||
|
homeDir: '/home/testuser',
|
||||||
|
platform: 'darwin',
|
||||||
|
mcpUrl: 'http://127.0.0.1:4124/mcp',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAgents', () => {
|
||||||
|
it('returns all supported agent definitions', () => {
|
||||||
|
const agents = engine.getAgents();
|
||||||
|
expect(agents).toHaveLength(4);
|
||||||
|
const ids = agents.map((a) => a.id);
|
||||||
|
expect(ids).toContain('claude-code');
|
||||||
|
expect(ids).toContain('github-copilot');
|
||||||
|
expect(ids).toContain('gemini-cli');
|
||||||
|
expect(ids).toContain('opencode');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes display labels for each agent', () => {
|
||||||
|
const agents = engine.getAgents();
|
||||||
|
for (const agent of agents) {
|
||||||
|
expect(agent.label).toBeTruthy();
|
||||||
|
expect(typeof agent.label).toBe('string');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getConfigPath', () => {
|
||||||
|
it('returns ~/.claude.json for claude-code', () => {
|
||||||
|
expect(engine.getConfigPath('claude-code')).toBe('/home/testuser/.claude.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns macOS VS Code user mcp.json for github-copilot on darwin', () => {
|
||||||
|
expect(engine.getConfigPath('github-copilot')).toBe(
|
||||||
|
'/home/testuser/Library/Application Support/Code/User/mcp.json',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Linux VS Code user mcp.json for github-copilot on linux', () => {
|
||||||
|
const linuxEngine = new MCPAgentConfigEngine({
|
||||||
|
homeDir: '/home/user',
|
||||||
|
platform: 'linux',
|
||||||
|
mcpUrl: 'http://127.0.0.1:4124/mcp',
|
||||||
|
});
|
||||||
|
expect(linuxEngine.getConfigPath('github-copilot')).toBe(
|
||||||
|
'/home/user/.config/Code/User/mcp.json',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns ~/.gemini/settings.json for gemini-cli', () => {
|
||||||
|
expect(engine.getConfigPath('gemini-cli')).toBe('/home/testuser/.gemini/settings.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns ~/.opencode.json for opencode', () => {
|
||||||
|
expect(engine.getConfigPath('opencode')).toBe('/home/testuser/.opencode.json');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addToConfig (claude-code)', () => {
|
||||||
|
it('creates new config when file does not exist', () => {
|
||||||
|
mockExistsSync.mockReturnValue(false);
|
||||||
|
|
||||||
|
const result = engine.addToConfig('claude-code');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.configPath).toBe('/home/testuser/.claude.json');
|
||||||
|
expect(mockWriteFileSync).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||||
|
expect(written.mcpServers.bDS).toEqual({ type: 'http', url: 'http://127.0.0.1:4124/mcp' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges into existing config without overwriting other servers', () => {
|
||||||
|
mockExistsSync.mockReturnValue(true);
|
||||||
|
mockReadFileSync.mockReturnValue(
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: { other: { type: 'stdio', command: 'npx' } },
|
||||||
|
someOtherKey: 'keep',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = engine.addToConfig('claude-code');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||||
|
expect(written.mcpServers.other).toEqual({ type: 'stdio', command: 'npx' });
|
||||||
|
expect(written.mcpServers.bDS).toEqual({ type: 'http', url: 'http://127.0.0.1:4124/mcp' });
|
||||||
|
expect(written.someOtherKey).toBe('keep');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overwrites existing bDS entry to update URL', () => {
|
||||||
|
mockExistsSync.mockReturnValue(true);
|
||||||
|
mockReadFileSync.mockReturnValue(
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: { bDS: { type: 'http', url: 'http://old:1234/mcp' } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = engine.addToConfig('claude-code');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||||
|
expect(written.mcpServers.bDS.url).toBe('http://127.0.0.1:4124/mcp');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addToConfig (github-copilot)', () => {
|
||||||
|
it('creates new .vscode/mcp.json with correct servers key', () => {
|
||||||
|
mockExistsSync.mockReturnValue(false);
|
||||||
|
|
||||||
|
const result = engine.addToConfig('github-copilot');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||||
|
expect(written.servers.bDS).toEqual({ type: 'http', url: 'http://127.0.0.1:4124/mcp' });
|
||||||
|
// Should NOT have mcpServers key
|
||||||
|
expect(written.mcpServers).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates parent directory if needed', () => {
|
||||||
|
mockExistsSync.mockReturnValue(false);
|
||||||
|
|
||||||
|
engine.addToConfig('github-copilot');
|
||||||
|
|
||||||
|
expect(mockMkdirSync).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Code/User'),
|
||||||
|
{ recursive: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges into existing VS Code config preserving other servers', () => {
|
||||||
|
mockExistsSync.mockReturnValue(true);
|
||||||
|
mockReadFileSync.mockReturnValue(
|
||||||
|
JSON.stringify({
|
||||||
|
servers: { github: { type: 'http', url: 'https://api.githubcopilot.com/mcp' } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = engine.addToConfig('github-copilot');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||||
|
expect(written.servers.github.url).toBe('https://api.githubcopilot.com/mcp');
|
||||||
|
expect(written.servers.bDS.url).toBe('http://127.0.0.1:4124/mcp');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addToConfig (gemini-cli)', () => {
|
||||||
|
it('creates new settings.json with httpUrl entry', () => {
|
||||||
|
mockExistsSync.mockReturnValue(false);
|
||||||
|
|
||||||
|
const result = engine.addToConfig('gemini-cli');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||||
|
expect(written.mcpServers.bDS).toEqual({ httpUrl: 'http://127.0.0.1:4124/mcp' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates ~/.gemini directory if needed', () => {
|
||||||
|
mockExistsSync.mockReturnValue(false);
|
||||||
|
|
||||||
|
engine.addToConfig('gemini-cli');
|
||||||
|
|
||||||
|
expect(mockMkdirSync).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('.gemini'),
|
||||||
|
{ recursive: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves existing settings when adding MCP server', () => {
|
||||||
|
mockExistsSync.mockReturnValue(true);
|
||||||
|
mockReadFileSync.mockReturnValue(
|
||||||
|
JSON.stringify({
|
||||||
|
theme: 'dark',
|
||||||
|
mcpServers: { existing: { command: 'python' } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = engine.addToConfig('gemini-cli');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||||
|
expect(written.theme).toBe('dark');
|
||||||
|
expect(written.mcpServers.existing).toEqual({ command: 'python' });
|
||||||
|
expect(written.mcpServers.bDS).toEqual({ httpUrl: 'http://127.0.0.1:4124/mcp' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addToConfig (opencode)', () => {
|
||||||
|
it('creates new .opencode.json with sse type', () => {
|
||||||
|
mockExistsSync.mockReturnValue(false);
|
||||||
|
|
||||||
|
const result = engine.addToConfig('opencode');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||||
|
expect(written.mcpServers.bDS).toEqual({ type: 'sse', url: 'http://127.0.0.1:4124/mcp' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges into existing opencode config', () => {
|
||||||
|
mockExistsSync.mockReturnValue(true);
|
||||||
|
mockReadFileSync.mockReturnValue(
|
||||||
|
JSON.stringify({
|
||||||
|
providers: { openai: { apiKey: 'key' } },
|
||||||
|
mcpServers: { debugger: { type: 'stdio', command: 'debug' } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = engine.addToConfig('opencode');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||||
|
expect(written.providers.openai.apiKey).toBe('key');
|
||||||
|
expect(written.mcpServers.debugger.command).toBe('debug');
|
||||||
|
expect(written.mcpServers.bDS.type).toBe('sse');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('returns error result when read fails', () => {
|
||||||
|
mockExistsSync.mockReturnValue(true);
|
||||||
|
mockReadFileSync.mockImplementation(() => {
|
||||||
|
throw new Error('Permission denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = engine.addToConfig('claude-code');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('Permission denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error result when write fails', () => {
|
||||||
|
mockExistsSync.mockReturnValue(false);
|
||||||
|
mockWriteFileSync.mockImplementation(() => {
|
||||||
|
throw new Error('Disk full');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = engine.addToConfig('claude-code');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('Disk full');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error for invalid existing JSON', () => {
|
||||||
|
mockExistsSync.mockReturnValue(true);
|
||||||
|
mockReadFileSync.mockReturnValue('not valid json{{{');
|
||||||
|
|
||||||
|
const result = engine.addToConfig('claude-code');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isConfigured', () => {
|
||||||
|
it('returns true when bDS entry exists in config', () => {
|
||||||
|
mockExistsSync.mockReturnValue(true);
|
||||||
|
mockReadFileSync.mockReturnValue(
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: { bDS: { type: 'http', url: 'http://127.0.0.1:4124/mcp' } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(engine.isConfigured('claude-code')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when config file does not exist', () => {
|
||||||
|
mockExistsSync.mockReturnValue(false);
|
||||||
|
|
||||||
|
expect(engine.isConfigured('claude-code')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when bDS entry is missing', () => {
|
||||||
|
mockExistsSync.mockReturnValue(true);
|
||||||
|
mockReadFileSync.mockReturnValue(
|
||||||
|
JSON.stringify({ mcpServers: { other: {} } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(engine.isConfigured('claude-code')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks VS Code servers key for github-copilot', () => {
|
||||||
|
mockExistsSync.mockReturnValue(true);
|
||||||
|
mockReadFileSync.mockReturnValue(
|
||||||
|
JSON.stringify({ servers: { bDS: { type: 'http', url: 'x' } } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(engine.isConfigured('github-copilot')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dynamic port', () => {
|
||||||
|
it('uses the provided mcpUrl in server entries', () => {
|
||||||
|
const customEngine = new MCPAgentConfigEngine({
|
||||||
|
homeDir: '/tmp',
|
||||||
|
platform: 'darwin',
|
||||||
|
mcpUrl: 'http://127.0.0.1:9999/mcp',
|
||||||
|
});
|
||||||
|
mockExistsSync.mockReturnValue(false);
|
||||||
|
|
||||||
|
customEngine.addToConfig('claude-code');
|
||||||
|
|
||||||
|
const written = JSON.parse(mockWriteFileSync.mock.calls[0]![1] as string);
|
||||||
|
expect(written.mcpServers.bDS.url).toBe('http://127.0.0.1:9999/mcp');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,9 +32,11 @@ function createMockDeps(): MCPServerDependencies {
|
|||||||
}),
|
}),
|
||||||
getScriptEngine: () => ({
|
getScriptEngine: () => ({
|
||||||
createScript: vi.fn().mockResolvedValue({ id: 's1' }),
|
createScript: vi.fn().mockResolvedValue({ id: 's1' }),
|
||||||
|
validateScript: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
|
||||||
}),
|
}),
|
||||||
getTemplateEngine: () => ({
|
getTemplateEngine: () => ({
|
||||||
createTemplate: vi.fn().mockResolvedValue({ id: 't1' }),
|
createTemplate: vi.fn().mockResolvedValue({ id: 't1' }),
|
||||||
|
validateTemplate: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
|
||||||
}),
|
}),
|
||||||
getMetaEngine: () => ({
|
getMetaEngine: () => ({
|
||||||
getProjectMetadata: vi.fn().mockResolvedValue(null),
|
getProjectMetadata: vi.fn().mockResolvedValue(null),
|
||||||
|
|||||||
@@ -1380,7 +1380,7 @@ tags: ["nature", "sunset"]`;
|
|||||||
return chain;
|
return chain;
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await mediaEngine.getMediaFiltered({ year: 2024, month: 5 }); // June (0-indexed)
|
const result = await mediaEngine.getMediaFiltered({ year: 2024, month: 6 }); // June (1-indexed)
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1452,9 +1452,9 @@ tags: ["nature", "sunset"]`;
|
|||||||
|
|
||||||
const result = await mediaEngine.getMediaByYearMonth();
|
const result = await mediaEngine.getMediaByYearMonth();
|
||||||
|
|
||||||
// Note: month is 0-indexed from Date.getMonth()
|
// month is 1-indexed
|
||||||
expect(result).toContainEqual({ year: 2024, month: 0, count: 2 }); // January
|
expect(result).toContainEqual({ year: 2024, month: 1, count: 2 }); // January
|
||||||
expect(result).toContainEqual({ year: 2024, month: 1, count: 1 }); // February
|
expect(result).toContainEqual({ year: 2024, month: 2, count: 1 }); // February
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should sort by year and month descending', async () => {
|
it('should sort by year and month descending', async () => {
|
||||||
@@ -1479,7 +1479,7 @@ tags: ["nature", "sunset"]`;
|
|||||||
const result = await mediaEngine.getMediaByYearMonth();
|
const result = await mediaEngine.getMediaByYearMonth();
|
||||||
|
|
||||||
expect(result[0].year).toBe(2024);
|
expect(result[0].year).toBe(2024);
|
||||||
expect(result[0].month).toBe(2); // March is month 2 (0-indexed)
|
expect(result[0].month).toBe(3); // March is month 3 (1-indexed)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2653,10 +2653,10 @@ Published snapshot content`);
|
|||||||
|
|
||||||
const result = await postEngine.getPostsByYearMonth();
|
const result = await postEngine.getPostsByYearMonth();
|
||||||
|
|
||||||
// Note: getMonth() returns 0-11, so January is 0, February is 1, etc.
|
// months are 1-indexed (January=1, February=2, etc.)
|
||||||
expect(result).toContainEqual({ year: 2024, month: 0, count: 2 }); // January
|
expect(result).toContainEqual({ year: 2024, month: 1, count: 2 }); // January
|
||||||
expect(result).toContainEqual({ year: 2024, month: 1, count: 1 }); // February
|
expect(result).toContainEqual({ year: 2024, month: 2, count: 1 }); // February
|
||||||
expect(result).toContainEqual({ year: 2023, month: 11, count: 1 }); // December
|
expect(result).toContainEqual({ year: 2023, month: 12, count: 1 }); // December
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should sort by year and month descending', async () => {
|
it('should sort by year and month descending', async () => {
|
||||||
@@ -2677,7 +2677,7 @@ Published snapshot content`);
|
|||||||
const result = await postEngine.getPostsByYearMonth();
|
const result = await postEngine.getPostsByYearMonth();
|
||||||
|
|
||||||
expect(result[0].year).toBe(2024);
|
expect(result[0].year).toBe(2024);
|
||||||
expect(result[0].month).toBe(2); // March (0-indexed)
|
expect(result[0].month).toBe(3); // March (1-indexed)
|
||||||
expect(result[result.length - 1].year).toBe(2023);
|
expect(result[result.length - 1].year).toBe(2023);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ function makeEngine(posts: PostData[]): PostEngineLike {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filter.month !== undefined && filter.year !== undefined) {
|
if (filter.month !== undefined && filter.year !== undefined) {
|
||||||
result = result.filter((post) => post.createdAt.getUTCMonth() === filter.month);
|
result = result.filter((post) => post.createdAt.getUTCMonth() === filter.month - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.startDate) {
|
if (filter.startDate) {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function makeEngine(posts: PostData[], snapshotsById: Record<string, PostData |
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filter.month !== undefined && filter.year !== undefined) {
|
if (filter.month !== undefined && filter.year !== undefined) {
|
||||||
result = result.filter((post) => post.createdAt.getMonth() === filter.month);
|
result = result.filter((post) => post.createdAt.getMonth() === filter.month - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.startDate) {
|
if (filter.startDate) {
|
||||||
@@ -110,7 +110,7 @@ describe('SharedSnapshotService', () => {
|
|||||||
engine,
|
engine,
|
||||||
'my-post',
|
'my-post',
|
||||||
{ useDraftContent: true, draftPostId: 'draft-1' },
|
{ useDraftContent: true, draftPostId: 'draft-1' },
|
||||||
{ year: 2025, month: 2, day: 21 },
|
{ year: 2025, month: 3, day: 21 },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result?.id).toBe('draft-1');
|
expect(result?.id).toBe('draft-1');
|
||||||
@@ -125,7 +125,7 @@ describe('SharedSnapshotService', () => {
|
|||||||
findPublishedBySlug,
|
findPublishedBySlug,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await findSinglePostBySlug(engineWithShortcut, 'shortcut', undefined, { year: 2025, month: 0, day: 2 });
|
const result = await findSinglePostBySlug(engineWithShortcut, 'shortcut', undefined, { year: 2025, month: 1, day: 2 });
|
||||||
|
|
||||||
expect(result?.id).toBe('x1');
|
expect(result?.id).toBe('x1');
|
||||||
expect(findPublishedBySlug).toHaveBeenCalled();
|
expect(findPublishedBySlug).toHaveBeenCalled();
|
||||||
|
|||||||
@@ -468,11 +468,16 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
PreviewServer: MockPreviewServer,
|
PreviewServer: MockPreviewServer,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('fs', () => ({
|
vi.doMock('fs', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('fs')>();
|
||||||
|
const mocked = {
|
||||||
|
...actual,
|
||||||
existsSync: vi.fn((targetPath: string) => targetPath.includes('window-state.json')),
|
existsSync: vi.fn((targetPath: string) => targetPath.includes('window-state.json')),
|
||||||
readFileSync: vi.fn(() => JSON.stringify({ x: 120, y: 80, width: 1280, height: 820 })),
|
readFileSync: vi.fn(() => JSON.stringify({ x: 120, y: 80, width: 1280, height: 820 })),
|
||||||
writeFileSync: vi.fn(),
|
writeFileSync: vi.fn(),
|
||||||
}));
|
};
|
||||||
|
return { ...mocked, default: mocked };
|
||||||
|
});
|
||||||
|
|
||||||
vi.doMock('../../src/main/database', () => ({
|
vi.doMock('../../src/main/database', () => ({
|
||||||
getDatabase: vi.fn(() => ({
|
getDatabase: vi.fn(() => ({
|
||||||
@@ -593,11 +598,16 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
PreviewServer: MockPreviewServer,
|
PreviewServer: MockPreviewServer,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('fs', () => ({
|
vi.doMock('fs', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('fs')>();
|
||||||
|
const mocked = {
|
||||||
|
...actual,
|
||||||
existsSync: vi.fn((targetPath: string) => targetPath.includes('window-state.json')),
|
existsSync: vi.fn((targetPath: string) => targetPath.includes('window-state.json')),
|
||||||
readFileSync: vi.fn(() => JSON.stringify({ x: -40, y: -10, width: 1800, height: 1000 })),
|
readFileSync: vi.fn(() => JSON.stringify({ x: -40, y: -10, width: 1800, height: 1000 })),
|
||||||
writeFileSync: vi.fn(),
|
writeFileSync: vi.fn(),
|
||||||
}));
|
};
|
||||||
|
return { ...mocked, default: mocked };
|
||||||
|
});
|
||||||
|
|
||||||
vi.doMock('../../src/main/database', () => ({
|
vi.doMock('../../src/main/database', () => ({
|
||||||
getDatabase: vi.fn(() => ({
|
getDatabase: vi.fn(() => ({
|
||||||
|
|||||||
@@ -1,40 +1,68 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import path from 'path';
|
||||||
import {
|
import {
|
||||||
reviewPostHtml,
|
reviewPostHtml,
|
||||||
reviewScriptHtml,
|
reviewScriptHtml,
|
||||||
reviewTemplateHtml,
|
reviewTemplateHtml,
|
||||||
reviewMetadataHtml,
|
reviewMetadataHtml,
|
||||||
|
resolveMcpViewsDirs,
|
||||||
|
loadViewHtml,
|
||||||
} from '../../src/main/engine/mcp-views';
|
} from '../../src/main/engine/mcp-views';
|
||||||
|
|
||||||
|
const viewOpts = {
|
||||||
|
moduleDir: path.resolve(__dirname, '../../src/main/engine'),
|
||||||
|
};
|
||||||
|
|
||||||
describe('mcp-views', () => {
|
describe('mcp-views', () => {
|
||||||
|
describe('resolveMcpViewsDirs', () => {
|
||||||
|
it('returns candidate directories', () => {
|
||||||
|
const dirs = resolveMcpViewsDirs(viewOpts);
|
||||||
|
expect(dirs.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(dirs.some(d => d.includes('mcp-views'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadViewHtml', () => {
|
||||||
|
it('loads an existing view file', () => {
|
||||||
|
const html = loadViewHtml('review-post.html', viewOpts);
|
||||||
|
expect(html).toContain('<!DOCTYPE html>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for a non-existent view', () => {
|
||||||
|
expect(() => loadViewHtml('does-not-exist.html', viewOpts)).toThrow(
|
||||||
|
/not found/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('reviewPostHtml', () => {
|
describe('reviewPostHtml', () => {
|
||||||
it('returns valid HTML document', () => {
|
it('returns valid HTML document', () => {
|
||||||
const html = reviewPostHtml();
|
const html = reviewPostHtml(viewOpts);
|
||||||
expect(html).toContain('<!DOCTYPE html>');
|
expect(html).toContain('<!DOCTYPE html>');
|
||||||
expect(html).toContain('</html>');
|
expect(html).toContain('</html>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('contains App import from ext-apps', () => {
|
it('contains App import from ext-apps', () => {
|
||||||
const html = reviewPostHtml();
|
const html = reviewPostHtml(viewOpts);
|
||||||
expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps');
|
expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps');
|
||||||
expect(html).toContain('new App(');
|
expect(html).toContain('new App(');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('contains accept and discard buttons', () => {
|
it('contains accept and discard buttons', () => {
|
||||||
const html = reviewPostHtml();
|
const html = reviewPostHtml(viewOpts);
|
||||||
expect(html).toContain('acceptProposal()');
|
expect(html).toContain('acceptProposal()');
|
||||||
expect(html).toContain('discardProposal()');
|
expect(html).toContain('discardProposal()');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls accept_proposal and discard_proposal tools via app bridge', () => {
|
it('calls accept_proposal and discard_proposal tools via app bridge', () => {
|
||||||
const html = reviewPostHtml();
|
const html = reviewPostHtml(viewOpts);
|
||||||
expect(html).toContain('app.callServerTool');
|
expect(html).toContain('app.callServerTool');
|
||||||
expect(html).toContain('"accept_proposal"');
|
expect(html).toContain('"accept_proposal"');
|
||||||
expect(html).toContain('"discard_proposal"');
|
expect(html).toContain('"discard_proposal"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('contains post-specific UI elements', () => {
|
it('contains post-specific UI elements', () => {
|
||||||
const html = reviewPostHtml();
|
const html = reviewPostHtml(viewOpts);
|
||||||
expect(html).toContain('Review Post');
|
expect(html).toContain('Review Post');
|
||||||
expect(html).toContain('Publish');
|
expect(html).toContain('Publish');
|
||||||
expect(html).toContain('badge-draft');
|
expect(html).toContain('badge-draft');
|
||||||
@@ -42,13 +70,13 @@ describe('mcp-views', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders tool result data via ontoolresult handler', () => {
|
it('renders tool result data via ontoolresult handler', () => {
|
||||||
const html = reviewPostHtml();
|
const html = reviewPostHtml(viewOpts);
|
||||||
expect(html).toContain('app.ontoolresult');
|
expect(html).toContain('app.ontoolresult');
|
||||||
expect(html).toContain('renderReview');
|
expect(html).toContain('renderReview');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses XSS-safe escaping function', () => {
|
it('uses XSS-safe escaping function', () => {
|
||||||
const html = reviewPostHtml();
|
const html = reviewPostHtml(viewOpts);
|
||||||
expect(html).toContain('function esc(');
|
expect(html).toContain('function esc(');
|
||||||
expect(html).toContain('document.createElement("div")');
|
expect(html).toContain('document.createElement("div")');
|
||||||
});
|
});
|
||||||
@@ -56,24 +84,24 @@ describe('mcp-views', () => {
|
|||||||
|
|
||||||
describe('reviewScriptHtml', () => {
|
describe('reviewScriptHtml', () => {
|
||||||
it('returns valid HTML document', () => {
|
it('returns valid HTML document', () => {
|
||||||
const html = reviewScriptHtml();
|
const html = reviewScriptHtml(viewOpts);
|
||||||
expect(html).toContain('<!DOCTYPE html>');
|
expect(html).toContain('<!DOCTYPE html>');
|
||||||
expect(html).toContain('</html>');
|
expect(html).toContain('</html>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('contains App import from ext-apps', () => {
|
it('contains App import from ext-apps', () => {
|
||||||
const html = reviewScriptHtml();
|
const html = reviewScriptHtml(viewOpts);
|
||||||
expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps');
|
expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('contains accept and discard buttons', () => {
|
it('contains accept and discard buttons', () => {
|
||||||
const html = reviewScriptHtml();
|
const html = reviewScriptHtml(viewOpts);
|
||||||
expect(html).toContain('acceptProposal()');
|
expect(html).toContain('acceptProposal()');
|
||||||
expect(html).toContain('discardProposal()');
|
expect(html).toContain('discardProposal()');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('contains script-specific UI elements', () => {
|
it('contains script-specific UI elements', () => {
|
||||||
const html = reviewScriptHtml();
|
const html = reviewScriptHtml(viewOpts);
|
||||||
expect(html).toContain('Review Script');
|
expect(html).toContain('Review Script');
|
||||||
expect(html).toContain('Create Script');
|
expect(html).toContain('Create Script');
|
||||||
expect(html).toContain('Python Code');
|
expect(html).toContain('Python Code');
|
||||||
@@ -82,24 +110,24 @@ describe('mcp-views', () => {
|
|||||||
|
|
||||||
describe('reviewTemplateHtml', () => {
|
describe('reviewTemplateHtml', () => {
|
||||||
it('returns valid HTML document', () => {
|
it('returns valid HTML document', () => {
|
||||||
const html = reviewTemplateHtml();
|
const html = reviewTemplateHtml(viewOpts);
|
||||||
expect(html).toContain('<!DOCTYPE html>');
|
expect(html).toContain('<!DOCTYPE html>');
|
||||||
expect(html).toContain('</html>');
|
expect(html).toContain('</html>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('contains App import from ext-apps', () => {
|
it('contains App import from ext-apps', () => {
|
||||||
const html = reviewTemplateHtml();
|
const html = reviewTemplateHtml(viewOpts);
|
||||||
expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps');
|
expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('contains accept and discard buttons', () => {
|
it('contains accept and discard buttons', () => {
|
||||||
const html = reviewTemplateHtml();
|
const html = reviewTemplateHtml(viewOpts);
|
||||||
expect(html).toContain('acceptProposal()');
|
expect(html).toContain('acceptProposal()');
|
||||||
expect(html).toContain('discardProposal()');
|
expect(html).toContain('discardProposal()');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('contains template-specific UI elements', () => {
|
it('contains template-specific UI elements', () => {
|
||||||
const html = reviewTemplateHtml();
|
const html = reviewTemplateHtml(viewOpts);
|
||||||
expect(html).toContain('Review Template');
|
expect(html).toContain('Review Template');
|
||||||
expect(html).toContain('Create Template');
|
expect(html).toContain('Create Template');
|
||||||
expect(html).toContain('Liquid Template');
|
expect(html).toContain('Liquid Template');
|
||||||
@@ -108,24 +136,24 @@ describe('mcp-views', () => {
|
|||||||
|
|
||||||
describe('reviewMetadataHtml', () => {
|
describe('reviewMetadataHtml', () => {
|
||||||
it('returns valid HTML document', () => {
|
it('returns valid HTML document', () => {
|
||||||
const html = reviewMetadataHtml();
|
const html = reviewMetadataHtml(viewOpts);
|
||||||
expect(html).toContain('<!DOCTYPE html>');
|
expect(html).toContain('<!DOCTYPE html>');
|
||||||
expect(html).toContain('</html>');
|
expect(html).toContain('</html>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('contains App import from ext-apps', () => {
|
it('contains App import from ext-apps', () => {
|
||||||
const html = reviewMetadataHtml();
|
const html = reviewMetadataHtml(viewOpts);
|
||||||
expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps');
|
expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('contains accept and discard buttons', () => {
|
it('contains accept and discard buttons', () => {
|
||||||
const html = reviewMetadataHtml();
|
const html = reviewMetadataHtml(viewOpts);
|
||||||
expect(html).toContain('acceptProposal()');
|
expect(html).toContain('acceptProposal()');
|
||||||
expect(html).toContain('discardProposal()');
|
expect(html).toContain('discardProposal()');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('contains metadata-diff UI elements', () => {
|
it('contains metadata-diff UI elements', () => {
|
||||||
const html = reviewMetadataHtml();
|
const html = reviewMetadataHtml(viewOpts);
|
||||||
expect(html).toContain('Metadata Changes');
|
expect(html).toContain('Metadata Changes');
|
||||||
expect(html).toContain('Apply Changes');
|
expect(html).toContain('Apply Changes');
|
||||||
expect(html).toContain('diff-table');
|
expect(html).toContain('diff-table');
|
||||||
@@ -134,7 +162,7 @@ describe('mcp-views', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('contains diff formatting function', () => {
|
it('contains diff formatting function', () => {
|
||||||
const html = reviewMetadataHtml();
|
const html = reviewMetadataHtml(viewOpts);
|
||||||
expect(html).toContain('function fmt(');
|
expect(html).toContain('function fmt(');
|
||||||
expect(html).toContain('diff-old');
|
expect(html).toContain('diff-old');
|
||||||
expect(html).toContain('diff-new');
|
expect(html).toContain('diff-new');
|
||||||
@@ -143,10 +171,10 @@ describe('mcp-views', () => {
|
|||||||
|
|
||||||
describe('shared behavior', () => {
|
describe('shared behavior', () => {
|
||||||
const allViews = [
|
const allViews = [
|
||||||
{ name: 'reviewPostHtml', fn: reviewPostHtml },
|
{ name: 'reviewPostHtml', fn: () => reviewPostHtml(viewOpts) },
|
||||||
{ name: 'reviewScriptHtml', fn: reviewScriptHtml },
|
{ name: 'reviewScriptHtml', fn: () => reviewScriptHtml(viewOpts) },
|
||||||
{ name: 'reviewTemplateHtml', fn: reviewTemplateHtml },
|
{ name: 'reviewTemplateHtml', fn: () => reviewTemplateHtml(viewOpts) },
|
||||||
{ name: 'reviewMetadataHtml', fn: reviewMetadataHtml },
|
{ name: 'reviewMetadataHtml', fn: () => reviewMetadataHtml(viewOpts) },
|
||||||
];
|
];
|
||||||
|
|
||||||
it.each(allViews)('$name connects the App on load', ({ fn }) => {
|
it.each(allViews)('$name connects the App on load', ({ fn }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user