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.
Files changed (53) hide show
  1. package/assets/core/solution-architect.agent.md +71 -0
  2. package/assets/mcps/_catalog.yaml +17 -0
  3. package/assets/mcps/figma.mcp.yaml +35 -0
  4. package/assets/mcps/github.mcp.yaml +44 -0
  5. package/assets/mcps/linear.mcp.yaml +37 -0
  6. package/assets/mcps/notion.mcp.yaml +37 -0
  7. package/assets/mcps/pencil.mcp.yaml +32 -0
  8. package/assets/mcps/postgresql.mcp.yaml +39 -0
  9. package/assets/mcps/sentry.mcp.yaml +41 -0
  10. package/assets/mcps/slack.mcp.yaml +37 -0
  11. package/assets/mcps/vercel.mcp.yaml +39 -0
  12. package/dist/cli/src/commands/doctor.js +26 -0
  13. package/dist/cli/src/commands/init.js +43 -0
  14. package/dist/cli/src/commands/mcp.d.ts +2 -0
  15. package/dist/cli/src/commands/mcp.js +155 -0
  16. package/dist/cli/src/commands/sync-templates.d.ts +1 -0
  17. package/dist/cli/src/commands/sync-templates.js +125 -0
  18. package/dist/cli/src/index.js +7 -0
  19. package/dist/cli/src/mcp/__tests__/catalog.test.d.ts +1 -0
  20. package/dist/cli/src/mcp/__tests__/catalog.test.js +101 -0
  21. package/dist/cli/src/mcp/__tests__/detect.test.d.ts +1 -0
  22. package/dist/cli/src/mcp/__tests__/detect.test.js +84 -0
  23. package/dist/cli/src/mcp/__tests__/setup.test.d.ts +1 -0
  24. package/dist/cli/src/mcp/__tests__/setup.test.js +75 -0
  25. package/dist/cli/src/mcp/__tests__/validate.test.d.ts +1 -0
  26. package/dist/cli/src/mcp/__tests__/validate.test.js +42 -0
  27. package/dist/cli/src/mcp/catalog.d.ts +4 -0
  28. package/dist/cli/src/mcp/catalog.js +53 -0
  29. package/dist/cli/src/mcp/detect.d.ts +9 -0
  30. package/dist/cli/src/mcp/detect.js +75 -0
  31. package/dist/cli/src/mcp/setup.d.ts +4 -0
  32. package/dist/cli/src/mcp/setup.js +56 -0
  33. package/dist/cli/src/mcp/types.d.ts +68 -0
  34. package/dist/cli/src/mcp/types.js +1 -0
  35. package/dist/cli/src/mcp/validate.d.ts +2 -0
  36. package/dist/cli/src/mcp/validate.js +23 -0
  37. package/dist/cli/src/pencil/__tests__/detect.test.d.ts +1 -0
  38. package/dist/cli/src/pencil/__tests__/detect.test.js +71 -0
  39. package/dist/cli/src/pencil/__tests__/property-mapper.test.d.ts +1 -0
  40. package/dist/cli/src/pencil/__tests__/property-mapper.test.js +120 -0
  41. package/dist/cli/src/pencil/__tests__/template-sync.test.d.ts +1 -0
  42. package/dist/cli/src/pencil/__tests__/template-sync.test.js +95 -0
  43. package/dist/cli/src/pencil/detect.d.ts +21 -0
  44. package/dist/cli/src/pencil/detect.js +23 -0
  45. package/dist/cli/src/pencil/property-mapper.d.ts +41 -0
  46. package/dist/cli/src/pencil/property-mapper.js +106 -0
  47. package/dist/cli/src/pencil/template-sync.d.ts +36 -0
  48. package/dist/cli/src/pencil/template-sync.js +75 -0
  49. package/dist/core/squad-loader.d.ts +7 -0
  50. package/dist/core/squad-loader.js +12 -0
  51. package/dist/server/scheduler/__tests__/job-runner.test.js +2 -2
  52. package/dist/server/scheduler/__tests__/job-runner.test.js.map +1 -1
  53. package/package.json +1 -1
@@ -0,0 +1,155 @@
1
+ import { Command } from 'commander';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import yaml from 'js-yaml';
5
+ import { loadMcpDefinition, listAllMcpIds } from '../mcp/catalog.js';
6
+ import { detectMcp, detectExtension, resolvePlatformBinary } from '../mcp/detect.js';
7
+ import { writeMcpConfig, removeMcpConfig } from '../mcp/setup.js';
8
+ import { getConfiguredMcpIds } from '../mcp/validate.js';
9
+ import { getAssetsDir } from '../utils/config.js';
10
+ function getMcpsDir() {
11
+ const local = path.resolve('mcps');
12
+ if (fs.existsSync(local))
13
+ return local;
14
+ return path.join(getAssetsDir(), 'mcps');
15
+ }
16
+ export function mcpCommand() {
17
+ const mcp = new Command('mcp')
18
+ .description('Manage MCP integrations');
19
+ mcp
20
+ .command('list')
21
+ .description('List all MCPs: configured, available, unavailable')
22
+ .action(() => {
23
+ const mcpsDir = getMcpsDir();
24
+ const ids = listAllMcpIds(mcpsDir);
25
+ const configured = getConfiguredMcpIds(process.cwd());
26
+ const results = [];
27
+ for (const id of ids) {
28
+ const def = loadMcpDefinition(mcpsDir, id);
29
+ if (def)
30
+ results.push(detectMcp(def));
31
+ }
32
+ console.log('\nMCP Integrations:\n');
33
+ const configuredResults = results.filter(r => configured.includes(r.id));
34
+ if (configuredResults.length > 0) {
35
+ console.log(' CONFIGURED');
36
+ for (const r of configuredResults) {
37
+ console.log(` \u2705 ${r.id.padEnd(14)} \u2014 ${r.detail}`);
38
+ }
39
+ console.log('');
40
+ }
41
+ const available = results.filter(r => !configured.includes(r.id) && r.status !== 'unavailable');
42
+ if (available.length > 0) {
43
+ console.log(' AVAILABLE');
44
+ for (const r of available) {
45
+ console.log(` \u2b1c ${r.id.padEnd(14)} \u2014 ${r.detail}`);
46
+ }
47
+ console.log('');
48
+ }
49
+ const unavailable = results.filter(r => r.status === 'unavailable' && !configured.includes(r.id));
50
+ if (unavailable.length > 0) {
51
+ console.log(' UNAVAILABLE');
52
+ for (const r of unavailable) {
53
+ console.log(` \u26d4 ${r.id.padEnd(14)} \u2014 ${r.detail}`);
54
+ }
55
+ console.log('');
56
+ }
57
+ console.log('Run: expxagents mcp setup <id> to configure\n');
58
+ });
59
+ mcp
60
+ .command('setup <ids...>')
61
+ .description('Configure one or more MCPs')
62
+ .action(async (ids) => {
63
+ const mcpsDir = getMcpsDir();
64
+ const readline = await import('readline');
65
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
66
+ const ask = (q) => new Promise(r => rl.question(q, r));
67
+ for (const id of ids) {
68
+ const def = loadMcpDefinition(mcpsDir, id);
69
+ if (!def) {
70
+ console.log(`\u274c Unknown MCP: ${id}`);
71
+ continue;
72
+ }
73
+ console.log(`\n\ud83d\udce6 Setting up ${def.name} MCP...\n`);
74
+ let resolvedCommand;
75
+ if (def.detection.method === 'extension' && def.detection.extension) {
76
+ const ext = detectExtension(def.detection.extension.prefix);
77
+ if (!ext) {
78
+ console.log(`\u274c ${def.name} VSCode extension not found`);
79
+ continue;
80
+ }
81
+ const binary = resolvePlatformBinary();
82
+ resolvedCommand = path.join(ext.extensionPath, def.detection.extension.binary_dir, binary);
83
+ }
84
+ const authValues = {};
85
+ for (const auth of def.auth) {
86
+ console.log(` ${auth.hint}`);
87
+ const value = await ask(`? ${auth.label}: `);
88
+ authValues[auth.key] = value.trim();
89
+ }
90
+ writeMcpConfig(process.cwd(), def, authValues, resolvedCommand);
91
+ console.log(`\n\u2705 ${def.name} MCP configured\n`);
92
+ }
93
+ rl.close();
94
+ });
95
+ mcp
96
+ .command('remove <id>')
97
+ .description('Remove an MCP from .mcp.json and .env')
98
+ .option('-f, --force', 'Skip squad dependency check')
99
+ .action((id, options) => {
100
+ const configured = getConfiguredMcpIds(process.cwd());
101
+ if (!configured.includes(id)) {
102
+ console.log(`\u274c MCP "${id}" is not configured`);
103
+ return;
104
+ }
105
+ if (!options.force) {
106
+ const squadsDir = path.resolve('squads');
107
+ if (fs.existsSync(squadsDir)) {
108
+ const dependentSquads = [];
109
+ for (const entry of fs.readdirSync(squadsDir)) {
110
+ const yamlPath = path.join(squadsDir, entry, 'squad.yaml');
111
+ if (!fs.existsSync(yamlPath))
112
+ continue;
113
+ try {
114
+ const raw = yaml.load(fs.readFileSync(yamlPath, 'utf-8'));
115
+ const mcps = raw.squad?.mcps;
116
+ if (mcps?.includes(id))
117
+ dependentSquads.push(entry);
118
+ }
119
+ catch { /* skip */ }
120
+ }
121
+ if (dependentSquads.length > 0) {
122
+ console.log(`\u26a0\ufe0f Squads that depend on "${id}": ${dependentSquads.join(', ')}`);
123
+ console.log(` Use --force to remove anyway`);
124
+ return;
125
+ }
126
+ }
127
+ }
128
+ removeMcpConfig(process.cwd(), id);
129
+ console.log(`\u2705 Removed MCP: ${id}`);
130
+ });
131
+ mcp
132
+ .command('check [id]')
133
+ .description('Validate configured MCP connections')
134
+ .action((id) => {
135
+ const configured = getConfiguredMcpIds(process.cwd());
136
+ const toCheck = id ? [id] : configured;
137
+ let issues = 0;
138
+ console.log('\nChecking MCP connections...\n');
139
+ for (const mcpId of toCheck) {
140
+ if (!configured.includes(mcpId)) {
141
+ console.log(` \u274c ${mcpId.padEnd(14)} \u2014 Not configured`);
142
+ issues++;
143
+ continue;
144
+ }
145
+ console.log(` \u2705 ${mcpId.padEnd(14)} \u2014 Configured`);
146
+ }
147
+ if (issues > 0) {
148
+ console.log(`\n${issues} issue(s) found.\n`);
149
+ }
150
+ else {
151
+ console.log(`\nAll MCPs OK.\n`);
152
+ }
153
+ });
154
+ return mcp;
155
+ }
@@ -0,0 +1 @@
1
+ export declare function syncTemplatesCommand(squadFilter?: string): Promise<void>;
@@ -0,0 +1,125 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { findSquadDir, loadSquad } from '../../../core/squad-loader.js';
4
+ /**
5
+ * Find all .pen files in a squad's templates/ directory.
6
+ */
7
+ function findPenFiles(squadDir) {
8
+ const templatesDir = path.join(squadDir, 'templates');
9
+ if (!fs.existsSync(templatesDir))
10
+ return [];
11
+ return fs.readdirSync(templatesDir)
12
+ .filter(f => f.endsWith('.pen'))
13
+ .map(f => path.join(templatesDir, f));
14
+ }
15
+ /**
16
+ * Find all template .md files in a squad's templates/ directory.
17
+ */
18
+ function findTemplateMds(squadDir) {
19
+ const templatesDir = path.join(squadDir, 'templates');
20
+ if (!fs.existsSync(templatesDir))
21
+ return [];
22
+ return fs.readdirSync(templatesDir)
23
+ .filter(f => f.startsWith('template-') && f.endsWith('.md'));
24
+ }
25
+ /**
26
+ * Walk squads directory and find all squads with visual_templates enabled.
27
+ */
28
+ function findVisualSquads(squadsDir) {
29
+ const results = [];
30
+ function walk(dir, depth) {
31
+ if (depth > 4)
32
+ return;
33
+ const yamlPath = path.join(dir, 'squad.yaml');
34
+ if (fs.existsSync(yamlPath)) {
35
+ try {
36
+ const config = loadSquad(dir);
37
+ if (config.squad.visualTemplates?.enabled) {
38
+ results.push(dir);
39
+ }
40
+ }
41
+ catch {
42
+ // Invalid squad — skip
43
+ }
44
+ return;
45
+ }
46
+ let entries;
47
+ try {
48
+ entries = fs.readdirSync(dir, { withFileTypes: true });
49
+ }
50
+ catch {
51
+ return;
52
+ }
53
+ for (const entry of entries) {
54
+ if (!entry.isDirectory())
55
+ continue;
56
+ if (entry.name.startsWith('.') || entry.name.startsWith('_'))
57
+ continue;
58
+ walk(path.join(dir, entry.name), depth + 1);
59
+ }
60
+ }
61
+ walk(squadsDir, 0);
62
+ return results;
63
+ }
64
+ export async function syncTemplatesCommand(squadFilter) {
65
+ const cwd = process.cwd();
66
+ const squadsDir = path.join(cwd, 'squads');
67
+ if (!fs.existsSync(squadsDir)) {
68
+ console.error('No squads/ directory found. Run `expxagents init` first.');
69
+ process.exit(1);
70
+ }
71
+ let squadDirs;
72
+ if (squadFilter) {
73
+ const dir = findSquadDir(squadsDir, squadFilter);
74
+ if (!dir) {
75
+ console.error(`Squad "${squadFilter}" not found.`);
76
+ process.exit(1);
77
+ }
78
+ squadDirs = [dir];
79
+ }
80
+ else {
81
+ squadDirs = findVisualSquads(squadsDir);
82
+ }
83
+ if (squadDirs.length === 0) {
84
+ console.log('No squads with visual_templates found.');
85
+ console.log('Add `visual_templates: { enabled: true }` to squad.yaml to enable.');
86
+ return;
87
+ }
88
+ console.log(`\nSync Templates — ${squadDirs.length} visual squad(s) found\n`);
89
+ for (const squadDir of squadDirs) {
90
+ const config = loadSquad(squadDir);
91
+ const penFiles = findPenFiles(squadDir);
92
+ const existingMds = findTemplateMds(squadDir);
93
+ const squadName = config.squad.name;
94
+ console.log(`--- ${squadName} (${config.squad.code}) ---`);
95
+ if (penFiles.length === 0) {
96
+ console.log(' No .pen files found in templates/');
97
+ console.log(' Create a .pen file or use Pencil to design templates.');
98
+ // Create templates dir if it doesn't exist
99
+ const templatesDir = path.join(squadDir, 'templates');
100
+ if (!fs.existsSync(templatesDir)) {
101
+ fs.mkdirSync(templatesDir, { recursive: true });
102
+ console.log(' Created templates/ directory');
103
+ }
104
+ console.log('');
105
+ continue;
106
+ }
107
+ console.log(` .pen files: ${penFiles.map(f => path.basename(f)).join(', ')}`);
108
+ console.log(` Existing .md templates: ${existingMds.length}`);
109
+ console.log('');
110
+ console.log(' To sync, open the .pen file in Pencil (VSCode) and run:');
111
+ console.log(' 1. Open the .pen file in VSCode');
112
+ console.log(' 2. Use Claude Code with Pencil MCP to read frames');
113
+ console.log(' 3. The sync will generate/update template .md files');
114
+ console.log('');
115
+ // Report existing template .md files
116
+ if (existingMds.length > 0) {
117
+ console.log(' Current templates:');
118
+ for (const md of existingMds) {
119
+ console.log(` - ${md}`);
120
+ }
121
+ console.log('');
122
+ }
123
+ }
124
+ console.log('Sync complete.');
125
+ }
@@ -14,6 +14,8 @@ import { virtualOfficeCommand } from './commands/virtual-office.js';
14
14
  import { jarvisCommand } from './commands/jarvis.js';
15
15
  import { schedulerCommand } from './commands/scheduler.js';
16
16
  import { reorganizeCommand } from './commands/reorganize.js';
17
+ import { syncTemplatesCommand } from './commands/sync-templates.js';
18
+ import { mcpCommand } from './commands/mcp.js';
17
19
  import { ingestCommand } from './commands/ingest.js';
18
20
  import { knowledgeCommand } from './commands/knowledge.js';
19
21
  import { loginCommand } from './commands/login.js';
@@ -64,6 +66,10 @@ program
64
66
  .command('reorganize')
65
67
  .description('Reorganize squads into Setor > Grupo > Sessão > Squad hierarchy')
66
68
  .action(reorganizeCommand);
69
+ program
70
+ .command('sync-templates [squad]')
71
+ .description('Sync Pencil .pen templates to .md specs for visual squads')
72
+ .action(syncTemplatesCommand);
67
73
  program
68
74
  .command('install <skill>')
69
75
  .description('Install skill from registry')
@@ -94,6 +100,7 @@ program
94
100
  .description('Open Jarvis voice assistant in browser')
95
101
  .action(jarvisCommand);
96
102
  program.addCommand(schedulerCommand());
103
+ program.addCommand(mcpCommand());
97
104
  program
98
105
  .command('ingest <path>')
99
106
  .description('Ingest documents into the knowledge base')
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,101 @@
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 { loadMcpCatalog, loadMcpDefinition, listAllMcpIds } from '../catalog.js';
6
+ let tmpDir;
7
+ beforeEach(() => {
8
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-catalog-test-'));
9
+ });
10
+ afterEach(() => {
11
+ fs.rmSync(tmpDir, { recursive: true, force: true });
12
+ });
13
+ function writeCatalog(content) {
14
+ fs.writeFileSync(path.join(tmpDir, '_catalog.yaml'), content, 'utf-8');
15
+ }
16
+ function writeDefinition(id, content) {
17
+ fs.writeFileSync(path.join(tmpDir, `${id}.mcp.yaml`), content, 'utf-8');
18
+ }
19
+ describe('loadMcpCatalog', () => {
20
+ it('loads and parses _catalog.yaml', () => {
21
+ writeCatalog(`
22
+ catalog:
23
+ version: "1.0.0"
24
+ categories:
25
+ - name: development
26
+ mcps: [github]
27
+ - name: communication
28
+ mcps: [slack]
29
+ `);
30
+ const catalog = loadMcpCatalog(tmpDir);
31
+ expect(catalog.version).toBe('1.0.0');
32
+ expect(catalog.categories).toHaveLength(2);
33
+ expect(catalog.categories[0].name).toBe('development');
34
+ expect(catalog.categories[0].mcps).toEqual(['github']);
35
+ });
36
+ it('returns empty categories when catalog is missing', () => {
37
+ const catalog = loadMcpCatalog(tmpDir);
38
+ expect(catalog.categories).toEqual([]);
39
+ });
40
+ });
41
+ describe('listAllMcpIds', () => {
42
+ it('flattens all MCP IDs from categories', () => {
43
+ writeCatalog(`
44
+ catalog:
45
+ version: "1.0.0"
46
+ categories:
47
+ - name: dev
48
+ mcps: [github, postgresql]
49
+ - name: comm
50
+ mcps: [slack]
51
+ `);
52
+ const ids = listAllMcpIds(tmpDir);
53
+ expect(ids).toEqual(['github', 'postgresql', 'slack']);
54
+ });
55
+ });
56
+ describe('loadMcpDefinition', () => {
57
+ it('loads individual MCP definition by ID', () => {
58
+ writeDefinition('github', `
59
+ id: github
60
+ name: GitHub
61
+ description: PRs and issues
62
+ category: development
63
+ detection:
64
+ method: npm
65
+ npm:
66
+ package: "@modelcontextprotocol/server-github"
67
+ server:
68
+ command: npx
69
+ args: ["-y", "@modelcontextprotocol/server-github"]
70
+ env:
71
+ GITHUB_PERSONAL_ACCESS_TOKEN: "\${GITHUB_PERSONAL_ACCESS_TOKEN}"
72
+ auth:
73
+ - key: GITHUB_PERSONAL_ACCESS_TOKEN
74
+ label: "GitHub Token"
75
+ hint: "Create at github.com/settings/tokens"
76
+ required: true
77
+ tools:
78
+ - name: list_issues
79
+ description: List issues
80
+ relevant_sectors:
81
+ - development
82
+ `);
83
+ const def = loadMcpDefinition(tmpDir, 'github');
84
+ expect(def).not.toBeNull();
85
+ expect(def.id).toBe('github');
86
+ expect(def.name).toBe('GitHub');
87
+ expect(def.detection.method).toBe('npm');
88
+ expect(def.auth).toHaveLength(1);
89
+ expect(def.auth[0].key).toBe('GITHUB_PERSONAL_ACCESS_TOKEN');
90
+ expect(def.server.args).toContain('-y');
91
+ });
92
+ it('returns null for unknown MCP ID', () => {
93
+ const def = loadMcpDefinition(tmpDir, 'nonexistent');
94
+ expect(def).toBeNull();
95
+ });
96
+ it('handles malformed YAML gracefully', () => {
97
+ writeDefinition('broken', '{{{{not yaml');
98
+ const def = loadMcpDefinition(tmpDir, 'broken');
99
+ expect(def).toBeNull();
100
+ });
101
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { detectMcp, resolvePlatformBinary, detectExtension } from '../detect.js';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+ function makeDef(overrides) {
7
+ return {
8
+ name: overrides.id,
9
+ description: '',
10
+ category: 'test',
11
+ server: { command: 'npx', args: [], env: {} },
12
+ auth: [],
13
+ tools: [],
14
+ relevant_sectors: [],
15
+ ...overrides,
16
+ };
17
+ }
18
+ describe('resolvePlatformBinary', () => {
19
+ it('resolves darwin + arm64', () => {
20
+ expect(resolvePlatformBinary('darwin', 'arm64')).toBe('mcp-server-darwin-arm64');
21
+ });
22
+ it('resolves darwin + x64', () => {
23
+ expect(resolvePlatformBinary('darwin', 'x64')).toBe('mcp-server-darwin-x64');
24
+ });
25
+ it('resolves linux + x64', () => {
26
+ expect(resolvePlatformBinary('linux', 'x64')).toBe('mcp-server-linux-x64');
27
+ });
28
+ it('resolves win32 + x64 with .exe', () => {
29
+ expect(resolvePlatformBinary('win32', 'x64')).toBe('mcp-server-win32-x64.exe');
30
+ });
31
+ });
32
+ describe('detectExtension', () => {
33
+ let tmpDir;
34
+ beforeEach(() => {
35
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ext-detect-test-'));
36
+ });
37
+ afterEach(() => {
38
+ fs.rmSync(tmpDir, { recursive: true, force: true });
39
+ });
40
+ it('returns null when no extension found', () => {
41
+ const result = detectExtension('nonexistent-prefix-', tmpDir);
42
+ expect(result).toBeNull();
43
+ });
44
+ it('finds latest version', () => {
45
+ fs.mkdirSync(path.join(tmpDir, 'highagency.pencildev-0.6.30'));
46
+ fs.mkdirSync(path.join(tmpDir, 'highagency.pencildev-0.6.35'));
47
+ const result = detectExtension('highagency.pencildev-', tmpDir);
48
+ expect(result).not.toBeNull();
49
+ expect(result.version).toBe('0.6.35');
50
+ expect(result.extensionPath).toContain('0.6.35');
51
+ });
52
+ });
53
+ describe('detectMcp', () => {
54
+ it('npm detection — always returns available', () => {
55
+ const def = makeDef({
56
+ id: 'slack',
57
+ detection: { method: 'npm', npm: { package: '@modelcontextprotocol/server-slack' } },
58
+ });
59
+ const result = detectMcp(def);
60
+ expect(result.status).toBe('available');
61
+ expect(result.detail).toContain('npm');
62
+ });
63
+ it('cli detection — returns unavailable when command not found', () => {
64
+ const def = makeDef({
65
+ id: 'test-cli',
66
+ detection: { method: 'cli', cli: { command: 'nonexistent-binary-xyz', version_flag: '--version' } },
67
+ });
68
+ const result = detectMcp(def);
69
+ expect(result.status).toBe('unavailable');
70
+ });
71
+ it('hybrid detection — falls back to npm when CLI not found', () => {
72
+ const def = makeDef({
73
+ id: 'test-hybrid',
74
+ detection: {
75
+ method: 'hybrid',
76
+ cli: { command: 'nonexistent-binary-xyz', version_flag: '--version' },
77
+ npm: { package: 'some-package' },
78
+ },
79
+ });
80
+ const result = detectMcp(def);
81
+ expect(result.status).toBe('available');
82
+ expect(result.detail).toContain('npm');
83
+ });
84
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,75 @@
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 { writeMcpConfig, removeMcpConfig, readMcpJson } from '../setup.js';
6
+ let tmpDir;
7
+ beforeEach(() => {
8
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-setup-test-'));
9
+ });
10
+ afterEach(() => {
11
+ fs.rmSync(tmpDir, { recursive: true, force: true });
12
+ });
13
+ function makeDef(id) {
14
+ return {
15
+ id,
16
+ name: id,
17
+ description: '',
18
+ category: 'test',
19
+ detection: { method: 'npm', npm: { package: `@test/${id}` } },
20
+ server: { command: 'npx', args: ['-y', `@test/${id}`], env: { TOKEN: '${TOKEN}' } },
21
+ auth: [{ key: 'TOKEN', label: 'Token', hint: 'hint', required: true }],
22
+ tools: [],
23
+ relevant_sectors: [],
24
+ };
25
+ }
26
+ describe('writeMcpConfig', () => {
27
+ it('creates .mcp.json with server entry', () => {
28
+ writeMcpConfig(tmpDir, makeDef('github'), { TOKEN: 'abc123' });
29
+ const json = readMcpJson(tmpDir);
30
+ expect(json.mcpServers.github).toBeDefined();
31
+ expect(json.mcpServers.github.command).toBe('npx');
32
+ expect(json.mcpServers.github.args).toContain('-y');
33
+ });
34
+ it('merges into existing .mcp.json without overwriting', () => {
35
+ const existing = { mcpServers: { pencil: { command: '/bin/pencil', args: [], env: {} } } };
36
+ fs.writeFileSync(path.join(tmpDir, '.mcp.json'), JSON.stringify(existing, null, 2));
37
+ writeMcpConfig(tmpDir, makeDef('github'), { TOKEN: 'abc' });
38
+ const json = readMcpJson(tmpDir);
39
+ expect(json.mcpServers.pencil).toBeDefined();
40
+ expect(json.mcpServers.github).toBeDefined();
41
+ });
42
+ it('adds env vars to .env file', () => {
43
+ writeMcpConfig(tmpDir, makeDef('github'), { TOKEN: 'mytoken' });
44
+ const env = fs.readFileSync(path.join(tmpDir, '.env'), 'utf-8');
45
+ expect(env).toContain('TOKEN=mytoken');
46
+ });
47
+ it('adds placeholders to .env.example', () => {
48
+ writeMcpConfig(tmpDir, makeDef('github'), { TOKEN: 'mytoken' });
49
+ const example = fs.readFileSync(path.join(tmpDir, '.env.example'), 'utf-8');
50
+ expect(example).toContain('TOKEN=');
51
+ expect(example).not.toContain('mytoken');
52
+ });
53
+ it('handles extension-based MCP with resolved command', () => {
54
+ const def = makeDef('pencil');
55
+ def.server.command = null;
56
+ writeMcpConfig(tmpDir, def, {}, '/resolved/path/to/binary');
57
+ const json = readMcpJson(tmpDir);
58
+ expect(json.mcpServers.pencil.command).toBe('/resolved/path/to/binary');
59
+ });
60
+ });
61
+ describe('removeMcpConfig', () => {
62
+ it('removes MCP entry from .mcp.json', () => {
63
+ const existing = {
64
+ mcpServers: {
65
+ github: { command: 'npx', args: [], env: {} },
66
+ slack: { command: 'npx', args: [], env: {} },
67
+ },
68
+ };
69
+ fs.writeFileSync(path.join(tmpDir, '.mcp.json'), JSON.stringify(existing, null, 2));
70
+ removeMcpConfig(tmpDir, 'github');
71
+ const json = readMcpJson(tmpDir);
72
+ expect(json.mcpServers.github).toBeUndefined();
73
+ expect(json.mcpServers.slack).toBeDefined();
74
+ });
75
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { validateSquadMcps, getConfiguredMcpIds } from '../validate.js';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+ let tmpDir;
7
+ beforeEach(() => {
8
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-validate-test-'));
9
+ });
10
+ afterEach(() => {
11
+ fs.rmSync(tmpDir, { recursive: true, force: true });
12
+ });
13
+ describe('getConfiguredMcpIds', () => {
14
+ it('returns MCP IDs from .mcp.json', () => {
15
+ fs.writeFileSync(path.join(tmpDir, '.mcp.json'), JSON.stringify({
16
+ mcpServers: { github: { command: 'x', args: [], env: {} }, slack: { command: 'x', args: [], env: {} } },
17
+ }));
18
+ expect(getConfiguredMcpIds(tmpDir)).toEqual(['github', 'slack']);
19
+ });
20
+ it('returns empty array when .mcp.json missing', () => {
21
+ expect(getConfiguredMcpIds(tmpDir)).toEqual([]);
22
+ });
23
+ });
24
+ describe('validateSquadMcps', () => {
25
+ it('passes when squad has no mcps field', () => {
26
+ expect(() => validateSquadMcps(undefined, ['github'])).not.toThrow();
27
+ });
28
+ it('passes when squad has empty mcps', () => {
29
+ expect(() => validateSquadMcps([], ['github'])).not.toThrow();
30
+ });
31
+ it('passes when all required MCPs are configured', () => {
32
+ expect(() => validateSquadMcps(['github', 'slack'], ['github', 'slack', 'sentry'])).not.toThrow();
33
+ });
34
+ it('throws when required MCP is not configured', () => {
35
+ expect(() => validateSquadMcps(['github', 'linear'], ['github']))
36
+ .toThrow(/linear/i);
37
+ });
38
+ it('includes setup command in error message', () => {
39
+ expect(() => validateSquadMcps(['linear'], []))
40
+ .toThrow(/expxagents mcp setup/i);
41
+ });
42
+ });
@@ -0,0 +1,4 @@
1
+ import type { McpCatalog, McpDefinition } from './types.js';
2
+ export declare function loadMcpCatalog(mcpsDir: string): McpCatalog;
3
+ export declare function listAllMcpIds(mcpsDir: string): string[];
4
+ export declare function loadMcpDefinition(mcpsDir: string, id: string): McpDefinition | null;