diff --git a/package.json b/package.json index 7a4a097..fbc4f5b 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,11 @@ "title": "Add Value Directive", "category": "StepZen" }, + { + "command": "stepzen.addTool", + "title": "Add Tool Directive", + "category": "StepZen" + }, { "command": "stepzen.openSchemaVisualizer", "title": "Open Schema Visualizer", diff --git a/src/commands/addDirective.ts b/src/commands/addDirective.ts index 3add62b..b2f2f53 100644 --- a/src/commands/addDirective.ts +++ b/src/commands/addDirective.ts @@ -8,6 +8,7 @@ import { services } from '../services'; import { handleError } from '../errors'; import { addMaterializer } from './addMaterializer'; import { addValue } from './addValue'; +import { addTool } from './addTool'; /** * Directive option for the quick pick menu @@ -57,6 +58,13 @@ export async function addDirective() { detail: "Returns a constant value or the result of a script expression", icon: "symbol-constant", command: addValue + }, + { + label: "$(tools) @tool", + description: "Define LLM function tools", + detail: "Creates tools for LLM integration with GraphQL or prescribed operations", + icon: "tools", + command: addTool } // Future directives can be added here: // { diff --git a/src/commands/addTool.ts b/src/commands/addTool.ts new file mode 100644 index 0000000..7394f6e --- /dev/null +++ b/src/commands/addTool.ts @@ -0,0 +1,501 @@ +/** + * Copyright IBM Corp. 2025 + * Assisted by CursorAI + */ + +import * as vscode from 'vscode'; +import { services } from '../services'; +import { handleError } from '../errors'; +import { DirectiveBuilder } from '../utils/directiveBuilder'; + +/** + * Adds a @tool directive to a GraphQL schema + * Provides options for GraphQL tools and prescribed tools + * + * @returns Promise that resolves when the tool directive has been added or operation is cancelled + */ +export async function addTool() { + try { + services.logger.info("Starting Add Tool command"); + + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showErrorMessage('No active editor'); + services.logger.warn("Add Tool failed: No active editor"); + return; + } + + const document = editor.document; + const position = editor.selection.active; + const line = document.lineAt(position.line); + const lineText = line.text; + + // Check if we're on a schema definition line + const trimmed = lineText.trim(); + if (!trimmed.startsWith('schema') && !trimmed.includes('schema')) { + vscode.window.showInformationMessage( + 'Place cursor on a GraphQL schema definition to add @tool directive.\n\n' + + 'The @tool directive should be applied to the schema definition, typically in your index.graphql file:\n\n' + + 'schema\n' + + ' @sdl(files: [...])\n' + + ' @tool(name: "my-tool", ...)\n' + + '{\n' + + ' query: Query\n' + + '}' + ); + services.logger.warn("Add Tool failed: Not on a GraphQL schema definition"); + return; + } + + services.logger.info("Processing schema definition for @tool directive"); + + // Show options for tool type + const toolTypeChoices: vscode.QuickPickItem[] = [ + { + label: "$(symbol-method) GraphQL Tool", + description: "Schema subset for LLM-generated requests", + detail: "Creates a tool with a subset of your schema so LLM can generate GraphQL queries/mutations for specific operations" + }, + { + label: "$(file-code) Prescribed Tool", + description: "Persisted operation with LLM variable selection", + detail: "Tool that executes a specific persisted GraphQL operation with LLM-selected variable values" + }, + { + label: "$(edit) Custom Tool", + description: "Manual configuration", + detail: "Enter custom tool configuration manually" + } + ]; + + const selectedType = await vscode.window.showQuickPick(toolTypeChoices, { + placeHolder: 'Select the type of tool directive to add', + matchOnDescription: true, + matchOnDetail: true + }); + + if (!selectedType) { + services.logger.info("Add Tool cancelled by user"); + return; + } + + if (selectedType.label.includes("GraphQL Tool")) { + await handleGraphQLTool(editor, position); + } else if (selectedType.label.includes("Prescribed Tool")) { + await handlePrescribedTool(editor, position); + } else if (selectedType.label.includes("Custom Tool")) { + await handleCustomTool(editor, position); + } + + services.logger.info("Add Tool completed successfully"); + } catch (err) { + handleError(err); + } +} + +/** + * Handles adding a GraphQL tool directive + */ +async function handleGraphQLTool( + editor: vscode.TextEditor, + position: vscode.Position +) { + // Get tool name + const toolName = await vscode.window.showInputBox({ + prompt: 'Enter tool name (can include {endpoint_folder} and {endpoint_name} variables)', + placeHolder: 'my-graphql-tool or {endpoint_folder}-{endpoint_name}', + validateInput: (value) => { + if (!value.trim()) { + return 'Tool name cannot be empty'; + } + return undefined; + } + }); + + if (!toolName) { + return; + } + + // Get tool description (optional) + const description = await vscode.window.showInputBox({ + prompt: 'Enter tool description (optional)', + placeHolder: 'Description of what this tool does...' + }); + + // Get available root operation fields from schema + const fieldIndex = services.schemaIndex.getFieldIndex(); + const queryFields = fieldIndex['Query'] ? fieldIndex['Query'].map(field => field.name) : []; + const mutationFields = fieldIndex['Mutation'] ? fieldIndex['Mutation'].map(field => field.name) : []; + + // Simple choice: Query, Mutation, or Both + const operationTypeChoice = await vscode.window.showQuickPick([ + { + label: "$(search) Query fields only", + description: "Include Query operations (read-only)", + detail: `${queryFields.length} Query fields available` + }, + { + label: "$(edit) Mutation fields only", + description: "Include Mutation operations (write operations)", + detail: `${mutationFields.length} Mutation fields available` + }, + { + label: "$(database) Both Query and Mutation fields", + description: "Include both read and write operations", + detail: `${queryFields.length + mutationFields.length} total fields available` + } + ], { + placeHolder: 'Which operation types should this tool include?' + }); + + if (!operationTypeChoice) { + return; + } + + let visibilityPatterns: Array<{expose: boolean, types: string, fields: string}> = []; + let selectedFields: string[] = []; + + // Field selection based on choice + if (operationTypeChoice.label.includes("Query fields only")) { + if (queryFields.length > 0) { + selectedFields = await selectFields(queryFields, "Query"); + if (selectedFields.length === 0) { + return; + } + + const fieldPattern = selectedFields.join('|'); + visibilityPatterns = [ + { expose: true, types: "Query", fields: fieldPattern }, + { expose: false, types: "Mutation", fields: ".*" } + ]; + } else { + visibilityPatterns = [ + { expose: true, types: "Query", fields: ".*" }, + { expose: false, types: "Mutation", fields: ".*" } + ]; + } + } else if (operationTypeChoice.label.includes("Mutation fields only")) { + if (mutationFields.length > 0) { + selectedFields = await selectFields(mutationFields, "Mutation"); + if (selectedFields.length === 0) { + return; + } + + const fieldPattern = selectedFields.join('|'); + visibilityPatterns = [ + { expose: false, types: "Query", fields: ".*" }, + { expose: true, types: "Mutation", fields: fieldPattern } + ]; + } else { + visibilityPatterns = [ + { expose: false, types: "Query", fields: ".*" }, + { expose: true, types: "Mutation", fields: ".*" } + ]; + } + } else { // Both Query and Mutation + const allFields = [...queryFields.map(f => `Query.${f}`), ...mutationFields.map(f => `Mutation.${f}`)]; + + if (allFields.length > 0) { + const selectedAllFields = await selectFields(allFields, "Query and Mutation"); + if (selectedAllFields.length === 0) { + return; + } + + // Separate selected fields by type + const selectedQueryFields = selectedAllFields + .filter(f => f.startsWith('Query.')) + .map(f => f.replace('Query.', '')); + const selectedMutationFields = selectedAllFields + .filter(f => f.startsWith('Mutation.')) + .map(f => f.replace('Mutation.', '')); + + visibilityPatterns = []; + if (selectedQueryFields.length > 0) { + visibilityPatterns.push({ expose: true, types: "Query", fields: selectedQueryFields.join('|') }); + } + if (selectedMutationFields.length > 0) { + visibilityPatterns.push({ expose: true, types: "Mutation", fields: selectedMutationFields.join('|') }); + } + + // Hide unselected fields + if (selectedQueryFields.length < queryFields.length) { + const unselectedQuery = queryFields.filter(f => !selectedQueryFields.includes(f)); + if (unselectedQuery.length > 0) { + visibilityPatterns.push({ expose: false, types: "Query", fields: unselectedQuery.join('|') }); + } + } + if (selectedMutationFields.length < mutationFields.length) { + const unselectedMutation = mutationFields.filter(f => !selectedMutationFields.includes(f)); + if (unselectedMutation.length > 0) { + visibilityPatterns.push({ expose: false, types: "Mutation", fields: unselectedMutation.join('|') }); + } + } + } else { + // No fields found, use wildcard patterns + visibilityPatterns = [ + { expose: true, types: "Query", fields: ".*" }, + { expose: true, types: "Mutation", fields: ".*" } + ]; + } + } + + // Ask about parameter descriptions (simplified) + const addDescriptions = await vscode.window.showQuickPick([ + { label: "$(x) No", description: "Skip parameter descriptions" }, + { label: "$(check) Yes", description: "Add parameter descriptions" } + ], { + placeHolder: 'Add descriptions for GraphQL tool parameters?' + }); + + let descriptions: Array<{name: string, description: string}> = []; + + if (addDescriptions?.label.includes("Yes")) { + descriptions = [ + { name: "query", description: "The GraphQL query or mutation to execute" }, + { name: "operationName", description: "Name of the operation to execute (for documents with multiple operations)" }, + { name: "variables", description: "Variables to pass to the GraphQL operation" } + ]; + } + + // Create and insert the directive + const config = DirectiveBuilder.createToolConfig({ + name: toolName, + description: description || undefined, + visibilityPatterns: visibilityPatterns, + descriptions: descriptions.length > 0 ? descriptions : undefined + }); + + await DirectiveBuilder.insertDirective(config, { editor, position }); +} + +/** + * Helper function to select fields from available options + */ +async function selectFields( + availableFields: string[], + operationType: string +): Promise { + if (availableFields.length === 0) { + vscode.window.showInformationMessage(`No ${operationType} fields found in schema. Using wildcard pattern.`); + return []; + } + + // If only a few fields, show them all for selection + if (availableFields.length <= 10) { + const fieldItems = availableFields.map(field => ({ + label: field, + description: `Include ${field} in the tool`, + picked: true // Default to all selected + })); + + const selectedItems = await vscode.window.showQuickPick(fieldItems, { + placeHolder: `Select ${operationType} fields to include in the tool`, + canPickMany: true, + matchOnDescription: true + }); + + return selectedItems ? selectedItems.map(item => item.label) : []; + } else { + // Too many fields, offer pattern-based selection + const patternChoice = await vscode.window.showQuickPick([ + { + label: "$(check-all) All fields", + description: `Include all ${availableFields.length} ${operationType} fields`, + detail: "Uses .* pattern to include everything" + }, + { + label: "$(filter) Enter pattern", + description: "Enter a regex pattern to match field names", + detail: "e.g., 'customer.*' or 'get.*|list.*'" + }, + { + label: "$(list-unordered) Select specific fields", + description: "Choose from a list of available fields", + detail: `Browse and select from ${availableFields.length} fields` + } + ], { + placeHolder: `How would you like to select ${operationType} fields?` + }); + + if (!patternChoice) { + return []; + } + + if (patternChoice.label.includes("All fields")) { + return availableFields; + } else if (patternChoice.label.includes("Enter pattern")) { + const pattern = await vscode.window.showInputBox({ + prompt: `Enter regex pattern to match ${operationType} field names`, + placeHolder: 'customer.*|order.*|get.*', + validateInput: (value) => { + if (!value.trim()) { + return 'Pattern cannot be empty'; + } + try { + new RegExp(value); + return undefined; + } catch { + return 'Invalid regex pattern'; + } + } + }); + + if (!pattern) { + return []; + } + + // Filter fields by pattern + try { + const regex = new RegExp(pattern); + const matchingFields = availableFields.filter(field => regex.test(field)); + + if (matchingFields.length === 0) { + vscode.window.showWarningMessage(`No fields match pattern "${pattern}"`); + return []; + } + + vscode.window.showInformationMessage(`Pattern "${pattern}" matches ${matchingFields.length} fields`); + return matchingFields; + } catch { + vscode.window.showErrorMessage('Invalid regex pattern'); + return []; + } + } else { + // Select specific fields - show in chunks if too many + const fieldItems = availableFields.map(field => ({ + label: field, + description: `Include ${field} in the tool` + })); + + const selectedItems = await vscode.window.showQuickPick(fieldItems, { + placeHolder: `Select specific ${operationType} fields to include`, + canPickMany: true, + matchOnDescription: true + }); + + return selectedItems ? selectedItems.map(item => item.label) : []; + } + } +} + +/** + * Handles adding a prescribed tool directive + */ +async function handlePrescribedTool( + editor: vscode.TextEditor, + position: vscode.Position +) { + // Get tool name + const toolName = await vscode.window.showInputBox({ + prompt: 'Enter tool name (can include {endpoint_folder} and {endpoint_name} variables)', + placeHolder: 'my-prescribed-tool or {endpoint_folder}-{endpoint_name}', + validateInput: (value) => { + if (!value.trim()) { + return 'Tool name cannot be empty'; + } + return undefined; + } + }); + + if (!toolName) { + return; + } + + // Get tool description (optional) + const description = await vscode.window.showInputBox({ + prompt: 'Enter tool description (optional)', + placeHolder: 'Description of what this prescribed tool does...' + }); + + // Ask about variable descriptions + const addDescriptions = await vscode.window.showQuickPick([ + { label: "$(check) Yes", description: "Add variable descriptions" }, + { label: "$(x) No", description: "Skip variable descriptions" } + ], { + placeHolder: 'Add custom descriptions for operation variables?' + }); + + let descriptions: Array<{name: string, description: string}> = []; + + if (addDescriptions?.label.includes("Yes")) { + vscode.window.showInformationMessage( + 'For prescribed tools, add variable descriptions after creating the directive. ' + + 'Use the format: {name: "variableName", description: "description"}' + ); + + // Allow user to add some common variable descriptions + let addMore = true; + while (addMore) { + const varName = await vscode.window.showInputBox({ + prompt: 'Enter variable name (without $)', + placeHolder: 'email, userId, filter.since' + }); + + if (!varName) { + break; + } + + const varDesc = await vscode.window.showInputBox({ + prompt: `Enter description for variable "${varName}"`, + placeHolder: 'Description of this variable...' + }); + + if (varDesc && varDesc.trim()) { + descriptions.push({ name: varName, description: varDesc.trim() }); + } + + const continueAdding = await vscode.window.showQuickPick([ + { label: "$(add) Add another variable", description: "Continue adding variable descriptions" }, + { label: "$(check) Done", description: "Finish adding descriptions" } + ], { + placeHolder: 'Add more variable descriptions?' + }); + + addMore = continueAdding?.label.includes("Add another") || false; + } + } + + // Create and insert the directive + const config = DirectiveBuilder.createToolConfig({ + name: toolName, + description: description || undefined, + descriptions: descriptions.length > 0 ? descriptions : undefined + }); + + await DirectiveBuilder.insertDirective(config, { editor, position }); +} + +/** + * Handles adding a custom tool directive + */ +async function handleCustomTool( + editor: vscode.TextEditor, + position: vscode.Position +) { + // Get tool name + const toolName = await vscode.window.showInputBox({ + prompt: 'Enter tool name (can include {endpoint_folder} and {endpoint_name} variables)', + placeHolder: 'my-custom-tool or {endpoint_folder}-{endpoint_name}', + validateInput: (value) => { + if (!value.trim()) { + return 'Tool name cannot be empty'; + } + return undefined; + } + }); + + if (!toolName) { + return; + } + + // Create minimal directive and let user customize + const config = DirectiveBuilder.createToolConfig({ + name: toolName + }); + + await DirectiveBuilder.insertDirective(config, { editor, position }); + + vscode.window.showInformationMessage( + 'Basic @tool directive inserted. Customize it by adding description, graphql, or descriptions arguments as needed.' + ); +} \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index 727285f..a8775b3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -220,6 +220,10 @@ export async function activate(context: vscode.ExtensionContext) { const { addValue } = await import("./commands/addValue.js"); return addValue(); }), + safeRegisterCommand(COMMANDS.ADD_TOOL, async () => { + const { addTool } = await import("./commands/addTool.js"); + return addTool(); + }), safeRegisterCommand(COMMANDS.RUN_OPERATION, async (...args: unknown[]) => { const { runOperation } = await import("./commands/runRequest.js"); return runOperation(args[0] as any); diff --git a/src/test/unit/addTool.test.ts b/src/test/unit/addTool.test.ts new file mode 100644 index 0000000..30dbbc1 --- /dev/null +++ b/src/test/unit/addTool.test.ts @@ -0,0 +1,258 @@ +/** + * Copyright IBM Corp. 2025 + * Assisted by CursorAI + */ + +import * as assert from 'assert'; +import * as path from 'path'; +import { services } from '../../services'; +import { DirectiveBuilder } from '../../utils/directiveBuilder'; + +suite("Add Tool Test Suite", () => { + + suiteSetup(async () => { + // Scan the test schema before running tests + const fixturePath = path.join(__dirname, "..", "..", "..", "src", "test", "fixtures", "schema-sample", "index.graphql"); + await services.schemaIndex.scan(fixturePath); + }); + + test("should create GraphQL tool directive configuration", () => { + const config = DirectiveBuilder.createToolConfig({ + name: "my-graphql-tool", + description: "A GraphQL tool for testing", + graphql: [ + { expose: true, types: "Query", fields: "customers|products" }, + { expose: true, types: "Mutation", fields: "createOrder" } + ], + descriptions: [ + { name: "query", description: "The GraphQL query to execute" }, + { name: "variables", description: "Variables for the query" } + ] + }); + + assert.strictEqual(config.name, "tool"); + assert.strictEqual(config.arguments.length, 4); + + // Check name argument + const nameArg = config.arguments.find(arg => arg.name === "name"); + assert.ok(nameArg); + assert.strictEqual(nameArg.value, "my-graphql-tool"); + assert.strictEqual(nameArg.isString, true); + + // Check description argument + const descArg = config.arguments.find(arg => arg.name === "description"); + assert.ok(descArg); + assert.strictEqual(descArg.value, "A GraphQL tool for testing"); + + // Check graphql argument (visibility patterns) + const graphqlArg = config.arguments.find(arg => arg.name === "graphql"); + assert.ok(graphqlArg); + assert.ok(Array.isArray(graphqlArg.value)); + const visibilityPatterns = graphqlArg.value as Array<{expose: boolean, types: string, fields: string}>; + assert.strictEqual(visibilityPatterns.length, 2); + + // Check first pattern + assert.strictEqual(visibilityPatterns[0].expose, true); + assert.strictEqual(visibilityPatterns[0].types, "Query"); + assert.strictEqual(visibilityPatterns[0].fields, "customers|products"); + + // Check second pattern + assert.strictEqual(visibilityPatterns[1].expose, true); + assert.strictEqual(visibilityPatterns[1].types, "Mutation"); + assert.strictEqual(visibilityPatterns[1].fields, "createOrder"); + + // Check descriptions argument + const descriptionsArg = config.arguments.find(arg => arg.name === "descriptions"); + assert.ok(descriptionsArg); + assert.ok(Array.isArray(descriptionsArg.value)); + }); + + test("should create prescribed tool directive configuration", () => { + const config = DirectiveBuilder.createToolConfig({ + name: "my-prescribed-tool", + description: "A prescribed tool for testing", + descriptions: [ + { name: "email", description: "User email address" }, + { name: "filter.since", description: "Filter start date" } + ] + }); + + assert.strictEqual(config.name, "tool"); + assert.strictEqual(config.multiline, true); + assert.strictEqual(config.arguments.length, 3); + + // Check name argument + const nameArg = config.arguments.find(arg => arg.name === "name"); + assert.ok(nameArg, "Should have name argument"); + assert.strictEqual(nameArg.value, "my-prescribed-tool"); + + // Check description argument + const descArg = config.arguments.find(arg => arg.name === "description"); + assert.ok(descArg, "Should have description argument"); + assert.strictEqual(descArg.value, "A prescribed tool for testing"); + + // Should not have graphql argument for prescribed tools + const graphqlArg = config.arguments.find(arg => arg.name === "graphql"); + assert.strictEqual(graphqlArg, undefined, "Prescribed tool should not have graphql argument"); + + // Check descriptions argument + const descriptionsArg = config.arguments.find(arg => arg.name === "descriptions"); + assert.ok(descriptionsArg, "Should have descriptions argument"); + assert.ok(Array.isArray(descriptionsArg.value), "Descriptions value should be an array"); + const descriptions = descriptionsArg.value as Array<{name: string, description: string}>; + assert.strictEqual(descriptions.length, 2); + assert.strictEqual(descriptions[0].name, "email"); + assert.strictEqual(descriptions[0].description, "User email address"); + assert.strictEqual(descriptions[1].name, "filter.since"); + assert.strictEqual(descriptions[1].description, "Filter start date"); + }); + + test("should create minimal tool directive configuration", () => { + const config = DirectiveBuilder.createToolConfig({ + name: "simple-tool" + }); + + assert.strictEqual(config.name, "tool"); + assert.strictEqual(config.multiline, true); + assert.strictEqual(config.arguments.length, 1); + + // Check name argument + const nameArg = config.arguments.find(arg => arg.name === "name"); + assert.ok(nameArg, "Should have name argument"); + assert.strictEqual(nameArg.value, "simple-tool"); + assert.strictEqual(nameArg.isString, true); + }); + + test("should handle template variables in tool name", () => { + const config = DirectiveBuilder.createToolConfig({ + name: "{endpoint_folder}-{endpoint_name}-tool", + description: "Tool with template variables" + }); + + assert.strictEqual(config.name, "tool"); + assert.strictEqual(config.arguments.length, 2); + + const nameArg = config.arguments.find(arg => arg.name === "name"); + assert.ok(nameArg, "Should have name argument"); + assert.strictEqual(nameArg.value, "{endpoint_folder}-{endpoint_name}-tool"); + assert.strictEqual(nameArg.isString, true); + }); + + test("should build tool directive string correctly", () => { + const config = DirectiveBuilder.createToolConfig({ + name: "customer-orders-tool", + description: "Tool for customer and order operations", + graphql: [ + { expose: true, types: "Query", fields: "customers|orders" }, + { expose: true, types: "Mutation", fields: "createOrder" } + ] + }); + + const directiveString = DirectiveBuilder.buildDirective(config, ' ', ''); + + // Should not contain undefined values + assert.ok(!directiveString.includes('undefined'), 'Should not contain undefined values'); + + // Should contain the tool name + assert.ok(directiveString.includes('"customer-orders-tool"'), 'Should contain tool name'); + + // Should contain the description + assert.ok(directiveString.includes('"Tool for customer and order operations"'), 'Should contain description'); + + // Should contain the visibility patterns + assert.ok(directiveString.includes('expose: true'), 'Should contain expose: true'); + assert.ok(directiveString.includes('types: "Query"'), 'Should contain types: "Query"'); + assert.ok(directiveString.includes('fields: "customers|orders"'), 'Should contain fields pattern'); + assert.ok(directiveString.includes('types: "Mutation"'), 'Should contain types: "Mutation"'); + assert.ok(directiveString.includes('fields: "createOrder"'), 'Should contain Mutation field pattern'); + + // Should be properly formatted as multiline + assert.ok(directiveString.includes('@tool('), 'Should start with @tool('); + assert.ok(directiveString.includes('graphql: ['), 'Should have graphql array'); + }); + + test("should handle empty arrays correctly", () => { + const config = DirectiveBuilder.createToolConfig({ + name: "empty-arrays-tool", + graphql: [], + descriptions: [] + }); + + // Empty arrays should not be included in the configuration + assert.strictEqual(config.arguments.length, 1); // Only name argument + + const nameArg = config.arguments.find(arg => arg.name === "name"); + assert.ok(nameArg, "Should have name argument"); + + const graphqlArg = config.arguments.find(arg => arg.name === "graphql"); + assert.strictEqual(graphqlArg, undefined, "Should not include empty graphql array"); + + const descriptionsArg = config.arguments.find(arg => arg.name === "descriptions"); + assert.strictEqual(descriptionsArg, undefined, "Should not include empty descriptions array"); + }); + + test("should validate tool directive structure", () => { + // Test that the tool directive follows the expected structure from the issue description + const config = DirectiveBuilder.createToolConfig({ + name: "comprehensive-tool", + description: "A comprehensive tool with all features", + graphql: [ + { expose: true, types: "Query", fields: ".*" }, + { expose: true, types: "User", fields: ".*" }, + { expose: true, types: "Product", fields: "name" } + ], + descriptions: [ + { name: "query", description: "The GraphQL query or mutation operation" }, + { name: "operationName", description: "Name of the operation to execute" }, + { name: "variables", description: "Variables object for the operation" } + ] + }); + + // Verify the structure matches the @tool directive specification + assert.strictEqual(config.name, "tool"); + assert.ok(config.multiline, "Tool directive should be multiline"); + + // All required and optional arguments should be present + const argNames = config.arguments.map(arg => arg.name); + assert.ok(argNames.includes("name"), "Should have name argument"); + assert.ok(argNames.includes("description"), "Should have description argument"); + assert.ok(argNames.includes("graphql"), "Should have graphql argument"); + assert.ok(argNames.includes("descriptions"), "Should have descriptions argument"); + }); + + test("should handle complex visibility patterns", () => { + const config = DirectiveBuilder.createToolConfig({ + name: "pattern-tool", + graphql: [ + { expose: true, types: "Query", fields: ".*" }, + { expose: true, types: "User", fields: "id|name|email" }, + { expose: false, types: "Query", fields: "_.*" }, + { expose: true, types: "Product", fields: "name" } + ] + }); + + const graphqlArg = config.arguments.find(arg => arg.name === "graphql"); + assert.ok(graphqlArg, "Should have graphql argument"); + assert.ok(Array.isArray(graphqlArg.value), "GraphQL value should be an array"); + + const patterns = graphqlArg.value as Array<{expose: boolean, types: string, fields: string}>; + assert.strictEqual(patterns.length, 4); + + // Check each pattern + assert.strictEqual(patterns[0].expose, true); + assert.strictEqual(patterns[0].types, "Query"); + assert.strictEqual(patterns[0].fields, ".*"); + + assert.strictEqual(patterns[1].expose, true); + assert.strictEqual(patterns[1].types, "User"); + assert.strictEqual(patterns[1].fields, "id|name|email"); + + assert.strictEqual(patterns[2].expose, false); + assert.strictEqual(patterns[2].types, "Query"); + assert.strictEqual(patterns[2].fields, "_.*"); + + assert.strictEqual(patterns[3].expose, true); + assert.strictEqual(patterns[3].types, "Product"); + assert.strictEqual(patterns[3].fields, "name"); + }); +}); \ No newline at end of file diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 43e877b..7758e91 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -17,6 +17,7 @@ export const COMMANDS = { ADD_DIRECTIVE: "stepzen.addDirective", ADD_MATERIALIZER: "stepzen.addMaterializer", ADD_VALUE: "stepzen.addValue", + ADD_TOOL: "stepzen.addTool", RUN_OPERATION: "stepzen.runOperation", RUN_PERSISTED: "stepzen.runPersisted", CLEAR_RESULTS: "stepzen.clearResults", diff --git a/src/utils/directiveBuilder.ts b/src/utils/directiveBuilder.ts index 574208f..980edc3 100644 --- a/src/utils/directiveBuilder.ts +++ b/src/utils/directiveBuilder.ts @@ -91,11 +91,29 @@ export class DirectiveBuilder { value.forEach((item, _index) => { let formattedItem: string; - if (typeof item === 'object' && 'name' in item && 'field' in item) { + if (typeof item === 'string') { + // Handle string array items (like graphql visibility patterns) + formattedItem = `"${item}"`; + } else if (typeof item === 'object' && item !== null && 'expose' in item && 'types' in item && 'fields' in item) { + // Handle StepZen_VisibilityPattern objects like { expose: true, types: "Query", fields: ".*" } + const expose = item.expose !== undefined ? String(item.expose) : 'true'; + formattedItem = `{ expose: ${expose}, types: "${item.types}", fields: "${item.fields}" }`; + } else if (typeof item === 'object' && item !== null && 'name' in item && 'description' in item) { + // Handle description objects like { name: "query", description: "..." } + formattedItem = `{ name: "${item.name}", description: "${item.description}" }`; + } else if (typeof item === 'object' && item !== null && 'name' in item && 'field' in item) { // Handle argument mapping objects like { name: "arg", field: "field" } formattedItem = `{ name: "${item.name}", field: "${item.field}" }`; + } else if (typeof item === 'object' && item !== null) { + // Handle other object types + const objArgs = Object.entries(item).map(([key, val]) => { + const stringVal = typeof val === 'string' ? `"${val}"` : String(val); + return `${key}: ${stringVal}`; + }); + formattedItem = `{ ${objArgs.join(', ')} }`; } else { - formattedItem = this.formatArgument(item as DirectiveArgument, '', indentUnit); + // Handle primitive values + formattedItem = typeof item === 'string' ? `"${item}"` : String(item); } lines.push(`${innerIndent}${indentUnit}${formattedItem}`); }); @@ -216,4 +234,51 @@ export class DirectiveBuilder { multiline: args.length > 1 }; } + + /** + * Creates a tool directive configuration + */ + static createToolConfig(options: { + name: string; + description?: string; + graphql?: Array<{expose: boolean, types: string, fields: string}>; + visibilityPatterns?: Array<{expose: boolean, types: string, fields: string}>; + descriptions?: Array<{name: string, description: string}>; + }): DirectiveConfig { + const args: DirectiveArgument[] = [ + { name: 'name', value: options.name, isString: true } + ]; + + if (options.description) { + args.push({ + name: 'description', + value: options.description, + isString: true + }); + } + + // Handle both graphql and visibilityPatterns for backward compatibility + const patterns = options.visibilityPatterns || options.graphql; + if (patterns && patterns.length > 0) { + args.push({ + name: 'graphql', + value: patterns, + isString: false + }); + } + + if (options.descriptions && options.descriptions.length > 0) { + args.push({ + name: 'descriptions', + value: options.descriptions, + isString: false + }); + } + + return { + name: 'tool', + arguments: args, + multiline: true + }; + } } \ No newline at end of file