expxagents 0.18.2 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/core/solution-architect.agent.md +71 -0
- package/assets/mcps/_catalog.yaml +17 -0
- package/assets/mcps/figma.mcp.yaml +35 -0
- package/assets/mcps/github.mcp.yaml +44 -0
- package/assets/mcps/linear.mcp.yaml +37 -0
- package/assets/mcps/notion.mcp.yaml +37 -0
- package/assets/mcps/pencil.mcp.yaml +32 -0
- package/assets/mcps/postgresql.mcp.yaml +39 -0
- package/assets/mcps/sentry.mcp.yaml +41 -0
- package/assets/mcps/slack.mcp.yaml +37 -0
- package/assets/mcps/vercel.mcp.yaml +39 -0
- package/dist/cli/src/commands/doctor.js +26 -0
- package/dist/cli/src/commands/init.js +43 -0
- package/dist/cli/src/commands/mcp.d.ts +2 -0
- package/dist/cli/src/commands/mcp.js +155 -0
- package/dist/cli/src/commands/sync-templates.d.ts +1 -0
- package/dist/cli/src/commands/sync-templates.js +125 -0
- package/dist/cli/src/index.js +7 -0
- package/dist/cli/src/mcp/__tests__/catalog.test.d.ts +1 -0
- package/dist/cli/src/mcp/__tests__/catalog.test.js +101 -0
- package/dist/cli/src/mcp/__tests__/detect.test.d.ts +1 -0
- package/dist/cli/src/mcp/__tests__/detect.test.js +84 -0
- package/dist/cli/src/mcp/__tests__/setup.test.d.ts +1 -0
- package/dist/cli/src/mcp/__tests__/setup.test.js +75 -0
- package/dist/cli/src/mcp/__tests__/validate.test.d.ts +1 -0
- package/dist/cli/src/mcp/__tests__/validate.test.js +42 -0
- package/dist/cli/src/mcp/catalog.d.ts +4 -0
- package/dist/cli/src/mcp/catalog.js +53 -0
- package/dist/cli/src/mcp/detect.d.ts +9 -0
- package/dist/cli/src/mcp/detect.js +75 -0
- package/dist/cli/src/mcp/setup.d.ts +4 -0
- package/dist/cli/src/mcp/setup.js +56 -0
- package/dist/cli/src/mcp/types.d.ts +68 -0
- package/dist/cli/src/mcp/types.js +1 -0
- package/dist/cli/src/mcp/validate.d.ts +2 -0
- package/dist/cli/src/mcp/validate.js +23 -0
- package/dist/cli/src/pencil/__tests__/detect.test.d.ts +1 -0
- package/dist/cli/src/pencil/__tests__/detect.test.js +71 -0
- package/dist/cli/src/pencil/__tests__/property-mapper.test.d.ts +1 -0
- package/dist/cli/src/pencil/__tests__/property-mapper.test.js +120 -0
- package/dist/cli/src/pencil/__tests__/template-sync.test.d.ts +1 -0
- package/dist/cli/src/pencil/__tests__/template-sync.test.js +95 -0
- package/dist/cli/src/pencil/detect.d.ts +21 -0
- package/dist/cli/src/pencil/detect.js +23 -0
- package/dist/cli/src/pencil/property-mapper.d.ts +41 -0
- package/dist/cli/src/pencil/property-mapper.js +106 -0
- package/dist/cli/src/pencil/template-sync.d.ts +36 -0
- package/dist/cli/src/pencil/template-sync.js +75 -0
- package/dist/core/squad-loader.d.ts +7 -0
- package/dist/core/squad-loader.js +12 -0
- package/dist/server/scheduler/__tests__/job-runner.test.js +2 -2
- package/dist/server/scheduler/__tests__/job-runner.test.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
export function loadMcpCatalog(mcpsDir) {
|
|
5
|
+
const catalogPath = path.join(mcpsDir, '_catalog.yaml');
|
|
6
|
+
if (!fs.existsSync(catalogPath)) {
|
|
7
|
+
return { version: '0.0.0', categories: [] };
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
const raw = fs.readFileSync(catalogPath, 'utf-8');
|
|
11
|
+
const parsed = yaml.load(raw);
|
|
12
|
+
const catalog = parsed?.catalog;
|
|
13
|
+
return {
|
|
14
|
+
version: catalog?.version ?? '0.0.0',
|
|
15
|
+
categories: catalog?.categories ?? [],
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { version: '0.0.0', categories: [] };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function listAllMcpIds(mcpsDir) {
|
|
23
|
+
const catalog = loadMcpCatalog(mcpsDir);
|
|
24
|
+
return catalog.categories.flatMap(c => c.mcps);
|
|
25
|
+
}
|
|
26
|
+
export function loadMcpDefinition(mcpsDir, id) {
|
|
27
|
+
const defPath = path.join(mcpsDir, `${id}.mcp.yaml`);
|
|
28
|
+
if (!fs.existsSync(defPath)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const raw = fs.readFileSync(defPath, 'utf-8');
|
|
33
|
+
const parsed = yaml.load(raw);
|
|
34
|
+
if (!parsed?.id || !parsed?.detection) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
id: parsed.id,
|
|
39
|
+
name: parsed.name ?? parsed.id,
|
|
40
|
+
description: parsed.description ?? '',
|
|
41
|
+
category: parsed.category ?? 'other',
|
|
42
|
+
icon: parsed.icon,
|
|
43
|
+
detection: parsed.detection,
|
|
44
|
+
server: parsed.server ?? { command: null, args: [], env: {} },
|
|
45
|
+
auth: parsed.auth ?? [],
|
|
46
|
+
tools: parsed.tools ?? [],
|
|
47
|
+
relevant_sectors: parsed.relevant_sectors ?? [],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { McpDefinition, McpDetectionResult } from './types.js';
|
|
2
|
+
export declare function resolvePlatformBinary(platform?: string, arch?: string): string;
|
|
3
|
+
export interface ExtensionInfo {
|
|
4
|
+
version: string;
|
|
5
|
+
extensionPath: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function detectExtension(prefix: string, extensionsDir?: string): ExtensionInfo | null;
|
|
8
|
+
export declare function detectMcp(def: McpDefinition, extensionsDir?: string): McpDetectionResult;
|
|
9
|
+
export declare function detectAllMcps(definitions: McpDefinition[], extensionsDir?: string): McpDetectionResult[];
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
export function resolvePlatformBinary(platform, arch) {
|
|
6
|
+
const p = platform ?? process.platform;
|
|
7
|
+
const a = arch ?? process.arch;
|
|
8
|
+
if (p === 'win32')
|
|
9
|
+
return 'mcp-server-win32-x64.exe';
|
|
10
|
+
return `mcp-server-${p}-${a}`;
|
|
11
|
+
}
|
|
12
|
+
export function detectExtension(prefix, extensionsDir) {
|
|
13
|
+
const dir = extensionsDir ?? path.join(os.homedir(), '.vscode', 'extensions');
|
|
14
|
+
if (!fs.existsSync(dir))
|
|
15
|
+
return null;
|
|
16
|
+
const entries = fs.readdirSync(dir)
|
|
17
|
+
.filter(e => e.startsWith(prefix))
|
|
18
|
+
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
|
|
19
|
+
if (entries.length === 0)
|
|
20
|
+
return null;
|
|
21
|
+
const latest = entries[entries.length - 1];
|
|
22
|
+
return {
|
|
23
|
+
version: latest.slice(prefix.length),
|
|
24
|
+
extensionPath: path.join(dir, latest),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function detectCli(command, versionFlag) {
|
|
28
|
+
try {
|
|
29
|
+
const output = execSync(`${command} ${versionFlag}`, { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
30
|
+
const match = output.match(/(\d+\.\d+[\.\d]*)/);
|
|
31
|
+
return { found: true, version: match?.[1] ?? 'unknown' };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return { found: false };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function detectMcp(def, extensionsDir) {
|
|
38
|
+
const base = { id: def.id, name: def.name };
|
|
39
|
+
const { method } = def.detection;
|
|
40
|
+
if (method === 'cli' && def.detection.cli) {
|
|
41
|
+
const { command, version_flag } = def.detection.cli;
|
|
42
|
+
const cli = detectCli(command, version_flag);
|
|
43
|
+
if (cli.found) {
|
|
44
|
+
return { ...base, status: 'detected', detail: `${command} v${cli.version} found`, version: cli.version };
|
|
45
|
+
}
|
|
46
|
+
return { ...base, status: 'unavailable', detail: `${command} not found in PATH` };
|
|
47
|
+
}
|
|
48
|
+
if (method === 'npm' && def.detection.npm) {
|
|
49
|
+
return { ...base, status: 'available', detail: `Available via npm (${def.detection.npm.package})` };
|
|
50
|
+
}
|
|
51
|
+
if (method === 'extension' && def.detection.extension) {
|
|
52
|
+
const ext = detectExtension(def.detection.extension.prefix, extensionsDir);
|
|
53
|
+
if (ext) {
|
|
54
|
+
return { ...base, status: 'detected', detail: `VSCode extension v${ext.version}`, version: ext.version, extensionPath: ext.extensionPath };
|
|
55
|
+
}
|
|
56
|
+
return { ...base, status: 'unavailable', detail: 'VSCode extension not found' };
|
|
57
|
+
}
|
|
58
|
+
if (method === 'hybrid') {
|
|
59
|
+
if (def.detection.cli) {
|
|
60
|
+
const { command, version_flag } = def.detection.cli;
|
|
61
|
+
const cli = detectCli(command, version_flag);
|
|
62
|
+
if (cli.found) {
|
|
63
|
+
return { ...base, status: 'detected', detail: `${command} v${cli.version} found`, version: cli.version };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (def.detection.npm) {
|
|
67
|
+
return { ...base, status: 'available', detail: `Available via npm (${def.detection.npm.package})` };
|
|
68
|
+
}
|
|
69
|
+
return { ...base, status: 'unavailable', detail: 'No CLI or npm package available' };
|
|
70
|
+
}
|
|
71
|
+
return { ...base, status: 'unavailable', detail: 'Unknown detection method' };
|
|
72
|
+
}
|
|
73
|
+
export function detectAllMcps(definitions, extensionsDir) {
|
|
74
|
+
return definitions.map(def => detectMcp(def, extensionsDir));
|
|
75
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { McpDefinition, McpJson } from './types.js';
|
|
2
|
+
export declare function readMcpJson(projectDir: string): McpJson;
|
|
3
|
+
export declare function writeMcpConfig(projectDir: string, def: McpDefinition, authValues: Record<string, string>, resolvedCommand?: string): void;
|
|
4
|
+
export declare function removeMcpConfig(projectDir: string, mcpId: string): void;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
export function readMcpJson(projectDir) {
|
|
4
|
+
const mcpPath = path.join(projectDir, '.mcp.json');
|
|
5
|
+
if (!fs.existsSync(mcpPath)) {
|
|
6
|
+
return { mcpServers: {} };
|
|
7
|
+
}
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(fs.readFileSync(mcpPath, 'utf-8'));
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return { mcpServers: {} };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function writeMcpConfig(projectDir, def, authValues, resolvedCommand) {
|
|
16
|
+
const mcpJson = readMcpJson(projectDir);
|
|
17
|
+
const command = resolvedCommand ?? def.server.command;
|
|
18
|
+
if (!command)
|
|
19
|
+
return;
|
|
20
|
+
mcpJson.mcpServers[def.id] = {
|
|
21
|
+
command,
|
|
22
|
+
args: def.server.args,
|
|
23
|
+
env: { ...def.server.env },
|
|
24
|
+
};
|
|
25
|
+
fs.writeFileSync(path.join(projectDir, '.mcp.json'), JSON.stringify(mcpJson, null, 2) + '\n');
|
|
26
|
+
// Write env vars
|
|
27
|
+
const envPath = path.join(projectDir, '.env');
|
|
28
|
+
const examplePath = path.join(projectDir, '.env.example');
|
|
29
|
+
let envContent = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf-8') : '';
|
|
30
|
+
let exampleContent = fs.existsSync(examplePath) ? fs.readFileSync(examplePath, 'utf-8') : '';
|
|
31
|
+
const mcpHeader = `\n# MCP: ${def.name}\n`;
|
|
32
|
+
let envBlock = '';
|
|
33
|
+
let exampleBlock = '';
|
|
34
|
+
for (const auth of def.auth) {
|
|
35
|
+
const value = authValues[auth.key] ?? '';
|
|
36
|
+
if (!envContent.includes(`${auth.key}=`)) {
|
|
37
|
+
envBlock += `${auth.key}=${value}\n`;
|
|
38
|
+
}
|
|
39
|
+
if (!exampleContent.includes(`${auth.key}=`)) {
|
|
40
|
+
exampleBlock += `${auth.key}=\n`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (envBlock) {
|
|
44
|
+
envContent += mcpHeader + envBlock;
|
|
45
|
+
fs.writeFileSync(envPath, envContent);
|
|
46
|
+
}
|
|
47
|
+
if (exampleBlock) {
|
|
48
|
+
exampleContent += mcpHeader + exampleBlock;
|
|
49
|
+
fs.writeFileSync(examplePath, exampleContent);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function removeMcpConfig(projectDir, mcpId) {
|
|
53
|
+
const mcpJson = readMcpJson(projectDir);
|
|
54
|
+
delete mcpJson.mcpServers[mcpId];
|
|
55
|
+
fs.writeFileSync(path.join(projectDir, '.mcp.json'), JSON.stringify(mcpJson, null, 2) + '\n');
|
|
56
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export interface McpAuthEntry {
|
|
2
|
+
key: string;
|
|
3
|
+
label: string;
|
|
4
|
+
hint: string;
|
|
5
|
+
required: boolean;
|
|
6
|
+
validate?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface McpToolEntry {
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
}
|
|
12
|
+
export interface McpDetectionConfig {
|
|
13
|
+
method: 'cli' | 'npm' | 'extension' | 'hybrid';
|
|
14
|
+
cli?: {
|
|
15
|
+
command: string;
|
|
16
|
+
version_flag: string;
|
|
17
|
+
min_version?: string;
|
|
18
|
+
};
|
|
19
|
+
npm?: {
|
|
20
|
+
package: string;
|
|
21
|
+
};
|
|
22
|
+
extension?: {
|
|
23
|
+
prefix: string;
|
|
24
|
+
binary_dir: string;
|
|
25
|
+
binary_resolve: 'platform';
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export interface McpServerConfig {
|
|
29
|
+
command: string | null;
|
|
30
|
+
args: string[];
|
|
31
|
+
env: Record<string, string>;
|
|
32
|
+
}
|
|
33
|
+
export interface McpDefinition {
|
|
34
|
+
id: string;
|
|
35
|
+
name: string;
|
|
36
|
+
description: string;
|
|
37
|
+
category: string;
|
|
38
|
+
icon?: string;
|
|
39
|
+
detection: McpDetectionConfig;
|
|
40
|
+
server: McpServerConfig;
|
|
41
|
+
auth: McpAuthEntry[];
|
|
42
|
+
tools: McpToolEntry[];
|
|
43
|
+
relevant_sectors: string[];
|
|
44
|
+
}
|
|
45
|
+
export interface McpCatalogCategory {
|
|
46
|
+
name: string;
|
|
47
|
+
mcps: string[];
|
|
48
|
+
}
|
|
49
|
+
export interface McpCatalog {
|
|
50
|
+
version: string;
|
|
51
|
+
categories: McpCatalogCategory[];
|
|
52
|
+
}
|
|
53
|
+
export interface McpDetectionResult {
|
|
54
|
+
id: string;
|
|
55
|
+
name: string;
|
|
56
|
+
status: 'detected' | 'available' | 'unavailable';
|
|
57
|
+
detail: string;
|
|
58
|
+
version?: string;
|
|
59
|
+
extensionPath?: string;
|
|
60
|
+
}
|
|
61
|
+
export interface McpJsonEntry {
|
|
62
|
+
command: string;
|
|
63
|
+
args: string[];
|
|
64
|
+
env: Record<string, string>;
|
|
65
|
+
}
|
|
66
|
+
export interface McpJson {
|
|
67
|
+
mcpServers: Record<string, McpJsonEntry>;
|
|
68
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
export function getConfiguredMcpIds(projectDir) {
|
|
4
|
+
const mcpPath = path.join(projectDir, '.mcp.json');
|
|
5
|
+
if (!fs.existsSync(mcpPath))
|
|
6
|
+
return [];
|
|
7
|
+
try {
|
|
8
|
+
const json = JSON.parse(fs.readFileSync(mcpPath, 'utf-8'));
|
|
9
|
+
return Object.keys(json.mcpServers ?? {});
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function validateSquadMcps(squadMcps, configuredMcps) {
|
|
16
|
+
if (!squadMcps || squadMcps.length === 0)
|
|
17
|
+
return;
|
|
18
|
+
const missing = squadMcps.filter(m => !configuredMcps.includes(m));
|
|
19
|
+
if (missing.length > 0) {
|
|
20
|
+
throw new Error(`Squad requires MCPs not configured: ${missing.join(', ')}.\n` +
|
|
21
|
+
`Run: expxagents mcp setup ${missing.join(' ')}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { detectPencilExtension, resolvePlatformBinary, buildMcpConfig, } from '../detect.js';
|
|
6
|
+
describe('pencil/detect', () => {
|
|
7
|
+
let tmpDir;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pencil-detect-'));
|
|
10
|
+
});
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
describe('detectPencilExtension()', () => {
|
|
15
|
+
it('returns null when no extension directories exist', () => {
|
|
16
|
+
const result = detectPencilExtension(tmpDir);
|
|
17
|
+
expect(result).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
it('returns the latest version when multiple versions exist', () => {
|
|
20
|
+
fs.mkdirSync(path.join(tmpDir, 'highagency.pencildev-0.6.33', 'out'), { recursive: true });
|
|
21
|
+
fs.mkdirSync(path.join(tmpDir, 'highagency.pencildev-0.6.35', 'out'), { recursive: true });
|
|
22
|
+
fs.mkdirSync(path.join(tmpDir, 'highagency.pencildev-0.6.34', 'out'), { recursive: true });
|
|
23
|
+
const result = detectPencilExtension(tmpDir);
|
|
24
|
+
expect(result).not.toBeNull();
|
|
25
|
+
expect(result.version).toBe('0.6.35');
|
|
26
|
+
expect(result.extensionPath).toContain('highagency.pencildev-0.6.35');
|
|
27
|
+
});
|
|
28
|
+
it('returns info for a single installed version', () => {
|
|
29
|
+
fs.mkdirSync(path.join(tmpDir, 'highagency.pencildev-0.6.33', 'out'), { recursive: true });
|
|
30
|
+
const result = detectPencilExtension(tmpDir);
|
|
31
|
+
expect(result).not.toBeNull();
|
|
32
|
+
expect(result.version).toBe('0.6.33');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe('resolvePlatformBinary()', () => {
|
|
36
|
+
it('returns darwin-arm64 for macOS ARM', () => {
|
|
37
|
+
const binary = resolvePlatformBinary('darwin', 'arm64');
|
|
38
|
+
expect(binary).toBe('mcp-server-darwin-arm64');
|
|
39
|
+
});
|
|
40
|
+
it('returns darwin-x64 for macOS Intel', () => {
|
|
41
|
+
const binary = resolvePlatformBinary('darwin', 'x64');
|
|
42
|
+
expect(binary).toBe('mcp-server-darwin-x64');
|
|
43
|
+
});
|
|
44
|
+
it('returns linux-x64 for Linux', () => {
|
|
45
|
+
const binary = resolvePlatformBinary('linux', 'x64');
|
|
46
|
+
expect(binary).toBe('mcp-server-linux-x64');
|
|
47
|
+
});
|
|
48
|
+
it('returns win32-x64.exe for Windows', () => {
|
|
49
|
+
const binary = resolvePlatformBinary('win32', 'x64');
|
|
50
|
+
expect(binary).toBe('mcp-server-win32-x64.exe');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe('buildMcpConfig()', () => {
|
|
54
|
+
it('generates correct config object', () => {
|
|
55
|
+
const info = {
|
|
56
|
+
version: '0.6.35',
|
|
57
|
+
extensionPath: '/Users/test/.vscode/extensions/highagency.pencildev-0.6.35',
|
|
58
|
+
};
|
|
59
|
+
const config = buildMcpConfig(info, 'mcp-server-darwin-arm64');
|
|
60
|
+
expect(config).toEqual({
|
|
61
|
+
mcpServers: {
|
|
62
|
+
pencil: {
|
|
63
|
+
command: '/Users/test/.vscode/extensions/highagency.pencildev-0.6.35/out/mcp-server-darwin-arm64',
|
|
64
|
+
args: ['--app', 'visual_studio_code'],
|
|
65
|
+
env: {},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mapNodeToCSS } from '../property-mapper.js';
|
|
3
|
+
describe('pencil/property-mapper', () => {
|
|
4
|
+
it('maps fill to background-color for frame nodes', () => {
|
|
5
|
+
const node = { type: 'frame', fill: '#FF5733' };
|
|
6
|
+
const css = mapNodeToCSS(node);
|
|
7
|
+
expect(css).toContain('background: #FF5733');
|
|
8
|
+
});
|
|
9
|
+
it('maps fill to color for text nodes', () => {
|
|
10
|
+
const node = { type: 'text', fill: '#333333' };
|
|
11
|
+
const css = mapNodeToCSS(node);
|
|
12
|
+
expect(css).toContain('color: #333333');
|
|
13
|
+
});
|
|
14
|
+
it('maps font properties', () => {
|
|
15
|
+
const node = {
|
|
16
|
+
type: 'text',
|
|
17
|
+
fontSize: 24,
|
|
18
|
+
fontWeight: '700',
|
|
19
|
+
fontFamily: 'Inter',
|
|
20
|
+
lineHeight: 1.5,
|
|
21
|
+
letterSpacing: 0.5,
|
|
22
|
+
};
|
|
23
|
+
const css = mapNodeToCSS(node);
|
|
24
|
+
expect(css).toContain('font-size: 24px');
|
|
25
|
+
expect(css).toContain('font-weight: 700');
|
|
26
|
+
expect(css).toContain('font-family: Inter');
|
|
27
|
+
expect(css).toContain('line-height: 1.5');
|
|
28
|
+
expect(css).toContain('letter-spacing: 0.5px');
|
|
29
|
+
});
|
|
30
|
+
it('maps padding array [T,R,B,L]', () => {
|
|
31
|
+
const node = { type: 'frame', padding: [10, 20, 10, 20] };
|
|
32
|
+
const css = mapNodeToCSS(node);
|
|
33
|
+
expect(css).toContain('padding: 10px 20px 10px 20px');
|
|
34
|
+
});
|
|
35
|
+
it('maps padding array [V,H]', () => {
|
|
36
|
+
const node = { type: 'frame', padding: [10, 20] };
|
|
37
|
+
const css = mapNodeToCSS(node);
|
|
38
|
+
expect(css).toContain('padding: 10px 20px');
|
|
39
|
+
});
|
|
40
|
+
it('maps single padding value', () => {
|
|
41
|
+
const node = { type: 'frame', padding: 16 };
|
|
42
|
+
const css = mapNodeToCSS(node);
|
|
43
|
+
expect(css).toContain('padding: 16px');
|
|
44
|
+
});
|
|
45
|
+
it('maps layout vertical to flex-direction column', () => {
|
|
46
|
+
const node = { type: 'frame', layout: 'vertical' };
|
|
47
|
+
const css = mapNodeToCSS(node);
|
|
48
|
+
expect(css).toContain('display: flex');
|
|
49
|
+
expect(css).toContain('flex-direction: column');
|
|
50
|
+
});
|
|
51
|
+
it('maps layout horizontal to flex-direction row', () => {
|
|
52
|
+
const node = { type: 'frame', layout: 'horizontal' };
|
|
53
|
+
const css = mapNodeToCSS(node);
|
|
54
|
+
expect(css).toContain('display: flex');
|
|
55
|
+
expect(css).toContain('flex-direction: row');
|
|
56
|
+
});
|
|
57
|
+
it('maps gap', () => {
|
|
58
|
+
const node = { type: 'frame', gap: 12 };
|
|
59
|
+
const css = mapNodeToCSS(node);
|
|
60
|
+
expect(css).toContain('gap: 12px');
|
|
61
|
+
});
|
|
62
|
+
it('maps width fill_container to width 100%', () => {
|
|
63
|
+
const node = { type: 'frame', width: 'fill_container' };
|
|
64
|
+
const css = mapNodeToCSS(node);
|
|
65
|
+
expect(css).toContain('width: 100%');
|
|
66
|
+
});
|
|
67
|
+
it('maps width fit_content', () => {
|
|
68
|
+
const node = { type: 'frame', width: 'fit_content' };
|
|
69
|
+
const css = mapNodeToCSS(node);
|
|
70
|
+
expect(css).toContain('width: fit-content');
|
|
71
|
+
});
|
|
72
|
+
it('maps numeric width to px', () => {
|
|
73
|
+
const node = { type: 'frame', width: 300 };
|
|
74
|
+
const css = mapNodeToCSS(node);
|
|
75
|
+
expect(css).toContain('width: 300px');
|
|
76
|
+
});
|
|
77
|
+
it('maps cornerRadius to border-radius', () => {
|
|
78
|
+
const node = { type: 'frame', cornerRadius: 8 };
|
|
79
|
+
const css = mapNodeToCSS(node);
|
|
80
|
+
expect(css).toContain('border-radius: 8px');
|
|
81
|
+
});
|
|
82
|
+
it('maps cornerRadius array', () => {
|
|
83
|
+
const node = { type: 'frame', cornerRadius: [8, 8, 0, 0] };
|
|
84
|
+
const css = mapNodeToCSS(node);
|
|
85
|
+
expect(css).toContain('border-radius: 8px 8px 0px 0px');
|
|
86
|
+
});
|
|
87
|
+
it('maps clip true to overflow hidden', () => {
|
|
88
|
+
const node = { type: 'frame', clip: true };
|
|
89
|
+
const css = mapNodeToCSS(node);
|
|
90
|
+
expect(css).toContain('overflow: hidden');
|
|
91
|
+
});
|
|
92
|
+
it('maps opacity', () => {
|
|
93
|
+
const node = { type: 'frame', opacity: 0.5 };
|
|
94
|
+
const css = mapNodeToCSS(node);
|
|
95
|
+
expect(css).toContain('opacity: 0.5');
|
|
96
|
+
});
|
|
97
|
+
it('maps justifyContent and alignItems', () => {
|
|
98
|
+
const node = {
|
|
99
|
+
type: 'frame',
|
|
100
|
+
justifyContent: 'center',
|
|
101
|
+
alignItems: 'flex-start',
|
|
102
|
+
};
|
|
103
|
+
const css = mapNodeToCSS(node);
|
|
104
|
+
expect(css).toContain('justify-content: center');
|
|
105
|
+
expect(css).toContain('align-items: flex-start');
|
|
106
|
+
});
|
|
107
|
+
it('maps stroke to border', () => {
|
|
108
|
+
const node = {
|
|
109
|
+
type: 'frame',
|
|
110
|
+
stroke: { thickness: 2, fill: '#000000' },
|
|
111
|
+
};
|
|
112
|
+
const css = mapNodeToCSS(node);
|
|
113
|
+
expect(css).toContain('border: 2px solid #000000');
|
|
114
|
+
});
|
|
115
|
+
it('returns empty string for empty node', () => {
|
|
116
|
+
const node = { type: 'frame' };
|
|
117
|
+
const css = mapNodeToCSS(node);
|
|
118
|
+
expect(css).toBe('');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseFrameName, generateTemplateMarkdown, computeSyncActions, } from '../template-sync.js';
|
|
3
|
+
describe('pencil/template-sync', () => {
|
|
4
|
+
describe('parseFrameName()', () => {
|
|
5
|
+
it('parses "Template 01 - Hero Banner"', () => {
|
|
6
|
+
const result = parseFrameName('Template 01 - Hero Banner');
|
|
7
|
+
expect(result).toEqual({ number: 1, name: 'Hero Banner', slug: 'hero-banner' });
|
|
8
|
+
});
|
|
9
|
+
it('parses "Template 12 - Call to Action"', () => {
|
|
10
|
+
const result = parseFrameName('Template 12 - Call to Action');
|
|
11
|
+
expect(result).toEqual({ number: 12, name: 'Call to Action', slug: 'call-to-action' });
|
|
12
|
+
});
|
|
13
|
+
it('returns null for non-template frame names', () => {
|
|
14
|
+
expect(parseFrameName('Random Frame')).toBeNull();
|
|
15
|
+
expect(parseFrameName('Background')).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
describe('generateTemplateMarkdown()', () => {
|
|
19
|
+
it('generates markdown with CSS from node properties', () => {
|
|
20
|
+
const frame = {
|
|
21
|
+
id: 'frame1',
|
|
22
|
+
name: 'Template 01 - Hero Banner',
|
|
23
|
+
width: 1080,
|
|
24
|
+
height: 1350,
|
|
25
|
+
children: [
|
|
26
|
+
{
|
|
27
|
+
id: 'child1',
|
|
28
|
+
name: 'Header',
|
|
29
|
+
type: 'frame',
|
|
30
|
+
fill: '#FFFFFF',
|
|
31
|
+
layout: 'vertical',
|
|
32
|
+
padding: [20, 24, 20, 24],
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
const md = generateTemplateMarkdown(frame);
|
|
37
|
+
expect(md).toContain('# Template 01 — Hero Banner');
|
|
38
|
+
expect(md).toContain('1080x1350');
|
|
39
|
+
expect(md).toContain('background: #FFFFFF');
|
|
40
|
+
expect(md).toContain('flex-direction: column');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe('computeSyncActions()', () => {
|
|
44
|
+
it('detects new templates (frame without .md)', () => {
|
|
45
|
+
const frames = [
|
|
46
|
+
{ id: 'f1', name: 'Template 01 - Hero', width: 1080, height: 1350, children: [] },
|
|
47
|
+
];
|
|
48
|
+
const existingMds = [];
|
|
49
|
+
const actions = computeSyncActions(frames, existingMds);
|
|
50
|
+
expect(actions).toHaveLength(1);
|
|
51
|
+
expect(actions[0].type).toBe('create');
|
|
52
|
+
expect(actions[0].filename).toBe('template-01-hero.md');
|
|
53
|
+
});
|
|
54
|
+
it('detects orphaned templates (.md without frame)', () => {
|
|
55
|
+
const frames = [];
|
|
56
|
+
const existingMds = ['template-01-hero.md'];
|
|
57
|
+
const actions = computeSyncActions(frames, existingMds);
|
|
58
|
+
expect(actions).toHaveLength(1);
|
|
59
|
+
expect(actions[0].type).toBe('orphan');
|
|
60
|
+
expect(actions[0].filename).toBe('template-01-hero.md');
|
|
61
|
+
});
|
|
62
|
+
it('detects existing templates that may need update', () => {
|
|
63
|
+
const frames = [
|
|
64
|
+
{ id: 'f1', name: 'Template 01 - Hero', width: 1080, height: 1350, children: [] },
|
|
65
|
+
];
|
|
66
|
+
const existingMds = ['template-01-hero.md'];
|
|
67
|
+
const actions = computeSyncActions(frames, existingMds);
|
|
68
|
+
expect(actions).toHaveLength(1);
|
|
69
|
+
expect(actions[0].type).toBe('update');
|
|
70
|
+
});
|
|
71
|
+
it('handles mix of new, existing, and orphaned', () => {
|
|
72
|
+
const frames = [
|
|
73
|
+
{ id: 'f1', name: 'Template 01 - Hero', width: 1080, height: 1350, children: [] },
|
|
74
|
+
{ id: 'f2', name: 'Template 03 - Footer', width: 1080, height: 1350, children: [] },
|
|
75
|
+
];
|
|
76
|
+
const existingMds = ['template-01-hero.md', 'template-02-sidebar.md'];
|
|
77
|
+
const actions = computeSyncActions(frames, existingMds);
|
|
78
|
+
const create = actions.filter(a => a.type === 'create');
|
|
79
|
+
const update = actions.filter(a => a.type === 'update');
|
|
80
|
+
const orphan = actions.filter(a => a.type === 'orphan');
|
|
81
|
+
expect(update).toHaveLength(1); // template-01 exists
|
|
82
|
+
expect(create).toHaveLength(1); // template-03 is new
|
|
83
|
+
expect(orphan).toHaveLength(1); // template-02 has no frame
|
|
84
|
+
});
|
|
85
|
+
it('skips non-template frames', () => {
|
|
86
|
+
const frames = [
|
|
87
|
+
{ id: 'f1', name: 'Background', width: 1920, height: 1080, children: [] },
|
|
88
|
+
{ id: 'f2', name: 'Template 01 - Hero', width: 1080, height: 1350, children: [] },
|
|
89
|
+
];
|
|
90
|
+
const actions = computeSyncActions(frames, []);
|
|
91
|
+
expect(actions).toHaveLength(1);
|
|
92
|
+
expect(actions[0].filename).toBe('template-01-hero.md');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @deprecated — Use cli/src/mcp/detect.ts instead. This file re-exports for backwards compatibility.
|
|
3
|
+
*/
|
|
4
|
+
export { resolvePlatformBinary } from '../mcp/detect.js';
|
|
5
|
+
export interface PencilInfo {
|
|
6
|
+
version: string;
|
|
7
|
+
extensionPath: string;
|
|
8
|
+
}
|
|
9
|
+
export interface McpConfig {
|
|
10
|
+
mcpServers: {
|
|
11
|
+
pencil: {
|
|
12
|
+
command: string;
|
|
13
|
+
args: string[];
|
|
14
|
+
env: Record<string, string>;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** @deprecated Use detectExtension from cli/src/mcp/detect.ts */
|
|
19
|
+
export declare function detectPencilExtension(extensionsDir?: string): PencilInfo | null;
|
|
20
|
+
/** @deprecated Use writeMcpConfig from cli/src/mcp/setup.ts */
|
|
21
|
+
export declare function buildMcpConfig(info: PencilInfo, binaryName: string): McpConfig;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @deprecated — Use cli/src/mcp/detect.ts instead. This file re-exports for backwards compatibility.
|
|
3
|
+
*/
|
|
4
|
+
export { resolvePlatformBinary } from '../mcp/detect.js';
|
|
5
|
+
import { detectExtension } from '../mcp/detect.js';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
const EXTENSION_PREFIX = 'highagency.pencildev-';
|
|
8
|
+
/** @deprecated Use detectExtension from cli/src/mcp/detect.ts */
|
|
9
|
+
export function detectPencilExtension(extensionsDir) {
|
|
10
|
+
return detectExtension(EXTENSION_PREFIX, extensionsDir);
|
|
11
|
+
}
|
|
12
|
+
/** @deprecated Use writeMcpConfig from cli/src/mcp/setup.ts */
|
|
13
|
+
export function buildMcpConfig(info, binaryName) {
|
|
14
|
+
return {
|
|
15
|
+
mcpServers: {
|
|
16
|
+
pencil: {
|
|
17
|
+
command: path.join(info.extensionPath, 'out', binaryName),
|
|
18
|
+
args: ['--app', 'visual_studio_code'],
|
|
19
|
+
env: {},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|