Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions .changeset/feat-integration-management.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@stackwright/cli": minor
"@stackwright/mcp": minor
---

Add integration management commands and MCP tools: `stackwright integrations list/get/add` CLI commands and `stackwright_list_integrations`, `stackwright_get_integration`, `stackwright_add_integration` MCP tools for managing OpenAPI, GraphQL, and REST integrations in stackwright.yml.
2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { registerGenerateAgentDocs } from './commands/generate-agent-docs';
import { registerGitOps } from './commands/git-ops';
import { registerBoard } from './commands/board';
import { registerCollection } from './commands/collection';
import { registerIntegration } from './commands/integration';
import { registerCompose } from './commands/compose';
import { registerPreview } from './commands/preview';
import { registerSBOM } from './commands/sbom';
Expand Down Expand Up @@ -39,6 +40,7 @@ async function main(): Promise<void> {
registerGitOps(program);
registerBoard(program);
registerCollection(program);
registerIntegration(program);
registerCompose(program);
registerPreview(program);
registerSBOM(program);
Expand Down
194 changes: 194 additions & 0 deletions packages/cli/src/commands/integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { Command } from 'commander';
import path from 'path';
import fs from 'fs-extra';
import chalk from 'chalk';
import yaml from 'js-yaml';
import { outputResult, outputError, formatError } from '../utils/json-output';
import { readSiteConfig, writeSiteConfig } from './site';

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export interface IntegrationEntry {
type: 'openapi' | 'graphql' | 'rest';
name: string;
[key: string]: unknown;
}

export interface ListIntegrationsResult {
integrations: IntegrationEntry[];
path: string;
}

export interface GetIntegrationResult {
integration: IntegrationEntry | null;
path: string;
}

export interface AddIntegrationResult {
path: string;
created: boolean;
updated: boolean;
}

// ---------------------------------------------------------------------------
// Pure functions (exported for programmatic use and MCP)
// ---------------------------------------------------------------------------

export function listIntegrations(siteConfigPath: string): ListIntegrationsResult {
const { content, path: resolvedPath } = readSiteConfig(siteConfigPath);
const raw = yaml.load(content) as Record<string, unknown>;
const integrations = (raw?.integrations as IntegrationEntry[] | undefined) ?? [];
return { integrations, path: resolvedPath };
}

export function getIntegration(siteConfigPath: string, name: string): GetIntegrationResult {
const { integrations, path: resolvedPath } = listIntegrations(siteConfigPath);
const integration = integrations.find((i) => i.name === name) ?? null;
return { integration, path: resolvedPath };
}

export function addIntegration(
siteConfigPath: string,
entry: IntegrationEntry
): AddIntegrationResult {
const { content, path: resolvedPath } = readSiteConfig(siteConfigPath);
const raw = yaml.load(content) as Record<string, unknown>;
const integrations = (raw?.integrations as IntegrationEntry[] | undefined) ?? [];

const existingIdx = integrations.findIndex((i) => i.name === entry.name);
const updated = existingIdx >= 0;
if (updated) {
integrations[existingIdx] = entry;
} else {
integrations.push(entry);
}

raw.integrations = integrations;
const newContent = yaml.dump(raw, { lineWidth: 120 });
writeSiteConfig(resolvedPath, newContent);
return { path: resolvedPath, created: !updated, updated };
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

function resolveSiteConfig(projectRoot: string): string {
const candidates = ['stackwright.yml', 'stackwright.yaml'];
for (const name of candidates) {
const p = path.join(projectRoot, name);
if (fs.existsSync(p)) return p;
}
return path.join(projectRoot, 'stackwright.yml');
}

// ---------------------------------------------------------------------------
// Commander registration
// ---------------------------------------------------------------------------

export function registerIntegration(program: Command): void {
const integration = program
.command('integrations')
.description('Manage Stackwright integrations (OpenAPI, GraphQL, REST)');

integration
.command('list')
.description('List all configured integrations')
.option('--project-root <path>', 'Path to project root', process.cwd())
.option('--json', 'Output as JSON')
.action((opts: { projectRoot: string; json?: boolean }) => {
const json = Boolean(opts.json);
try {
const siteConfigPath = resolveSiteConfig(opts.projectRoot);
const result = listIntegrations(siteConfigPath);
outputResult(result, { json }, () => {
if (result.integrations.length === 0) {
console.log(chalk.dim('No integrations configured.'));
return;
}
console.log(chalk.bold(`Integrations (${result.integrations.length}):`));
for (const i of result.integrations) {
console.log(` ${chalk.cyan(i.name)} ${chalk.dim(`[${i.type}]`)}`);
}
});
} catch (err) {
outputError(formatError(err), 'LIST_INTEGRATIONS_FAILED', { json });
}
});

integration
.command('get <name>')
.description('Show details for a specific integration')
.option('--project-root <path>', 'Path to project root', process.cwd())
.option('--json', 'Output as JSON')
.action((name: string, opts: { projectRoot: string; json?: boolean }) => {
const json = Boolean(opts.json);
try {
const siteConfigPath = resolveSiteConfig(opts.projectRoot);
const result = getIntegration(siteConfigPath, name);
if (!result.integration) {
outputError(`Integration "${name}" not found.`, 'NOT_FOUND', { json });
}
outputResult(result.integration, { json }, () => {
console.log(chalk.bold(`Integration: ${result.integration!.name}`));
console.log(yaml.dump(result.integration, { indent: 2 }));
});
} catch (err) {
outputError(formatError(err), 'GET_INTEGRATION_FAILED', { json });
}
});

integration
.command('add')
.description('Add or update an integration in stackwright.yml')
.requiredOption('--name <name>', 'Integration name (kebab-case)')
.requiredOption('--type <type>', 'Integration type: openapi, graphql, or rest')
.option('--spec <path>', 'Path to OpenAPI spec file (for openapi type)')
.option('--endpoint <url>', 'API endpoint URL (for graphql/rest type)')
.option('--project-root <path>', 'Path to project root', process.cwd())
.option('--json', 'Output as JSON')
.action(
(opts: {
name: string;
type: string;
spec?: string;
endpoint?: string;
projectRoot: string;
json?: boolean;
}) => {
const json = Boolean(opts.json);
const { type } = opts;
if (!['openapi', 'graphql', 'rest'].includes(type)) {
outputError(`Invalid type "${type}". Must be: openapi, graphql, rest`, 'INVALID_TYPE', {
json,
});
}
try {
const entry: IntegrationEntry = {
type: type as 'openapi' | 'graphql' | 'rest',
name: opts.name,
...(opts.spec ? { spec: opts.spec } : {}),
...(opts.endpoint ? { endpoint: opts.endpoint } : {}),
};
const siteConfigPath = resolveSiteConfig(opts.projectRoot);
const result = addIntegration(siteConfigPath, entry);
const verb = result.updated ? 'Updated' : 'Added';
outputResult({ verb, path: result.path, integration: entry }, { json }, () => {
console.log(
chalk.green(`✓ ${verb} integration "${entry.name}" [${entry.type}] in ${result.path}`)
);
});
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
outputError(
formatError(err),
code === 'VALIDATION_FAILED' ? 'VALIDATION_FAILED' : 'ADD_INTEGRATION_FAILED',
{ json },
2
);
}
}
);
}
7 changes: 7 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export { generateAgentDocs } from './commands/generate-agent-docs';
export { stageChanges, openPr } from './commands/git-ops';
export { getBoard, parseBoard } from './commands/board';
export { listCollections, addCollection, resolveContentDir } from './commands/collection';
export { listIntegrations, getIntegration, addIntegration } from './commands/integration';
export { composeSite } from './commands/compose';
export { preview } from './commands/preview';
export { validateSiteComposition } from './utils/site-validator';
Expand Down Expand Up @@ -55,6 +56,12 @@ export type {
CollectionListResult,
AddCollectionResult,
} from './commands/collection';
export type {
IntegrationEntry,
ListIntegrationsResult,
GetIntegrationResult,
AddIntegrationResult,
} from './commands/integration';
export type { ComposeSiteResult, ComposeSiteOptions } from './commands/compose';
export type { PreviewResult, PreviewOptions } from './commands/preview';
export type {
Expand Down
1 change: 0 additions & 1 deletion packages/core/src/components/base/CodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ export function CodeBlock({ code, language, lineNumbers = false, background }: C
</div>
)}
<pre
tabIndex={0}
style={{
margin: 0,
padding: theme.spacing.md,
Expand Down
2 changes: 2 additions & 0 deletions packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { registerProjectTools } from './tools/project.js';
import { registerGitOpsTools } from './tools/git-ops.js';
import { registerBoardTools } from './tools/board.js';
import { registerCollectionTools } from './tools/collections.js';
import { registerIntegrationTools } from './tools/integrations.js';
import { registerComposeTools } from './tools/compose.js';
import { registerRenderTools, closeBrowser } from './tools/render.js';
import { version } from '../package.json';
Expand All @@ -23,6 +24,7 @@ registerProjectTools(server);
registerGitOpsTools(server);
registerBoardTools(server);
registerCollectionTools(server);
registerIntegrationTools(server);
registerComposeTools(server);
registerRenderTools(server);

Expand Down
120 changes: 120 additions & 0 deletions packages/mcp/src/tools/integrations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import path from 'path';
import fs from 'fs';
import { listIntegrations, getIntegration, addIntegration } from '@stackwright/cli';

function resolveSiteConfig(projectRoot: string): string {
const candidates = ['stackwright.yml', 'stackwright.yaml'];
for (const name of candidates) {
const p = path.join(projectRoot, name);
if (fs.existsSync(p)) return p;
}
return path.join(projectRoot, 'stackwright.yml');
}

export function registerIntegrationTools(server: McpServer): void {
server.tool(
'stackwright_list_integrations',
'List all integrations configured in stackwright.yml (OpenAPI, GraphQL, REST).',
{
projectRoot: z.string().describe('Absolute path to the root of the Stackwright project'),
},
async ({ projectRoot }) => {
try {
const siteConfigPath = resolveSiteConfig(projectRoot);
const result = listIntegrations(siteConfigPath);
if (result.integrations.length === 0) {
return { content: [{ type: 'text', text: 'No integrations configured.' }] };
}
const lines = result.integrations.map((i) => {
const specPart = i.spec ? ` — spec: ${String(i.spec)}` : '';
const endpointPart = i.endpoint ? ` — endpoint: ${String(i.endpoint)}` : '';
return ` ${i.name} [${i.type}]${specPart}${endpointPart}`;
});
return {
content: [
{
type: 'text',
text: `Integrations (${result.integrations.length}):\n${lines.join('\n')}`,
},
],
};
} catch (err) {
return {
content: [{ type: 'text', text: `Error: ${(err as Error).message}` }],
isError: true,
};
}
}
);

server.tool(
'stackwright_get_integration',
'Get details for a specific integration by name from stackwright.yml.',
{
projectRoot: z.string().describe('Absolute path to the root of the Stackwright project'),
name: z.string().describe('The integration name (e.g. "logistics", "inventory")'),
},
async ({ projectRoot, name }) => {
try {
const siteConfigPath = resolveSiteConfig(projectRoot);
const result = getIntegration(siteConfigPath, name);
if (!result.integration) {
return {
content: [{ type: 'text', text: `Integration "${name}" not found.` }],
isError: true,
};
}
return {
content: [{ type: 'text', text: JSON.stringify(result.integration, null, 2) }],
};
} catch (err) {
return {
content: [{ type: 'text', text: `Error: ${(err as Error).message}` }],
isError: true,
};
}
}
);

server.tool(
'stackwright_add_integration',
'Add or update an integration in stackwright.yml. Supports OpenAPI, GraphQL, and REST integrations.',
{
projectRoot: z.string().describe('Absolute path to the root of the Stackwright project'),
name: z
.string()
.min(1)
.max(50)
.regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, 'Name must be kebab-case')
.describe('Unique integration name (kebab-case, e.g. "logistics-api")'),
type: z.enum(['openapi', 'graphql', 'rest']).describe('Integration type'),
spec: z.string().optional().describe('Path to OpenAPI spec file (for openapi type)'),
endpoint: z.string().optional().describe('API endpoint URL (for graphql/rest type)'),
},
async ({ projectRoot, name, type, spec, endpoint }) => {
try {
const siteConfigPath = resolveSiteConfig(projectRoot);
const entry = {
type,
name,
...(spec ? { spec } : {}),
...(endpoint ? { endpoint } : {}),
};
const result = addIntegration(siteConfigPath, entry);
const verb = result.updated ? 'Updated' : 'Added';
return {
content: [
{ type: 'text', text: `✓ ${verb} integration "${name}" [${type}] in ${result.path}` },
],
};
} catch (err) {
return {
content: [{ type: 'text', text: `Error: ${(err as Error).message}` }],
isError: true,
};
}
}
);
}
Loading