diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc index 758949e..8219399 100644 --- a/.oxfmtrc.jsonc +++ b/.oxfmtrc.jsonc @@ -3,4 +3,5 @@ "useTabs": true, "semi": true, "singleQuote": true, + "ignorePatterns": [".claude/settings.local.json"], } diff --git a/README.md b/README.md index ab32b8a..b64a42d 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,100 @@ await generateText({ [View full example](examples/ai-sdk-integration.ts) +### TanStack AI + +```typescript +import { chat } from "@tanstack/ai"; +import { openai } from "@tanstack/ai-openai"; +import { z } from "zod"; +import { StackOneToolSet } from "@stackone/ai"; + +const toolset = new StackOneToolSet({ + baseUrl: "https://api.stackone.com", + accountId: "your-account-id", +}); + +const tools = await toolset.fetchTools(); +const employeeTool = tools.getTool("bamboohr_get_employee"); + +// TanStack AI requires Zod schemas for tool input validation +const getEmployeeTool = { + name: employeeTool.name, + description: employeeTool.description, + inputSchema: z.object({ + id: z.string().describe("The employee ID"), + }), + execute: async (args: { id: string }) => { + return employeeTool.execute(args); + }, +}; + +const adapter = openai(); +const stream = chat({ + adapter, + model: "gpt-4o", + messages: [{ role: "user", content: "Get employee with id: abc123" }], + tools: [getEmployeeTool], +}); + +for await (const chunk of stream) { + // Process streaming chunks +} +``` + +[View full example](examples/tanstack-ai-integration.ts) + +### Claude Agent SDK + +```typescript +import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk"; +import { z } from "zod"; +import { StackOneToolSet } from "@stackone/ai"; + +const toolset = new StackOneToolSet({ + baseUrl: "https://api.stackone.com", + accountId: "your-account-id", +}); + +const tools = await toolset.fetchTools(); +const employeeTool = tools.getTool("bamboohr_get_employee"); + +// Create a Claude Agent SDK tool from the StackOne tool +const getEmployeeTool = tool( + employeeTool.name, + employeeTool.description, + { id: z.string().describe("The employee ID") }, + async (args) => { + const result = await employeeTool.execute(args); + return { content: [{ type: "text", text: JSON.stringify(result) }] }; + } +); + +// Create an MCP server with the StackOne tool +const mcpServer = createSdkMcpServer({ + name: "stackone-tools", + version: "1.0.0", + tools: [getEmployeeTool], +}); + +// Use with Claude Agent SDK query +const result = query({ + prompt: "Get the employee with id: abc123", + options: { + model: "claude-sonnet-4-5-20250929", + mcpServers: { "stackone-tools": mcpServer }, + tools: [], // Disable built-in tools + maxTurns: 3, + }, +}); + +for await (const message of result) { + // Process streaming messages +} +``` + +[View full example](examples/claude-agent-sdk-integration.ts) + ## Usage ```typescript @@ -128,8 +222,6 @@ const employeeTool = tools.getTool("bamboohr_list_employees"); const employees = await employeeTool.execute(); ``` -[View full example](examples/index.ts) - ### Authentication Set the `STACKONE_API_KEY` environment variable: diff --git a/examples/ai-sdk-integration.test.ts b/examples/ai-sdk-integration.test.ts new file mode 100644 index 0000000..79239b8 --- /dev/null +++ b/examples/ai-sdk-integration.test.ts @@ -0,0 +1,52 @@ +/** + * E2E test for ai-sdk-integration.ts example + * + * Tests the complete flow of using StackOne tools with the AI SDK. + */ + +import { openai } from '@ai-sdk/openai'; +import { generateText, stepCountIs } from 'ai'; +import { StackOneToolSet } from '../src'; + +describe('ai-sdk-integration example e2e', () => { + beforeEach(() => { + vi.stubEnv('STACKONE_API_KEY', 'test-key'); + vi.stubEnv('OPENAI_API_KEY', 'test-openai-key'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should fetch tools, convert to AI SDK format, and generate text with tool calls', async () => { + const toolset = new StackOneToolSet({ + accountId: 'your-bamboohr-account-id', + baseUrl: 'https://api.stackone.com', + }); + + // Fetch all tools for this account via MCP + const tools = await toolset.fetchTools(); + expect(tools.length).toBeGreaterThan(0); + + // Convert to AI SDK tools + const aiSdkTools = await tools.toAISDK(); + expect(aiSdkTools).toBeDefined(); + expect(Object.keys(aiSdkTools).length).toBeGreaterThan(0); + + // Verify the tools have the expected structure + const toolNames = Object.keys(aiSdkTools); + expect(toolNames).toContain('bamboohr_list_employees'); + expect(toolNames).toContain('bamboohr_get_employee'); + + // The AI SDK will automatically call the tool if needed + const { text } = await generateText({ + model: openai('gpt-5'), + tools: aiSdkTools, + prompt: 'Get all details about employee with id: c28xIQaWQ6MzM5MzczMDA2NzMzMzkwNzIwNA', + stopWhen: stepCountIs(3), + }); + + // The mocked OpenAI response includes 'Michael' in the text + expect(text).toContain('Michael'); + }); +}); diff --git a/examples/ai-sdk-integration.ts b/examples/ai-sdk-integration.ts index 4df068e..d39b2dc 100644 --- a/examples/ai-sdk-integration.ts +++ b/examples/ai-sdk-integration.ts @@ -1,5 +1,13 @@ /** * This example shows how to use StackOne tools with the AI SDK. + * + * The AI SDK provides an agent-like pattern through the `stopWhen` parameter + * with `stepCountIs()`. This creates a multi-step tool loop where the model + * can autonomously call tools and reason over results until the stop condition + * is met. + * + * In AI SDK v6+, you can use the `ToolLoopAgent` class for more explicit + * agent functionality. */ import assert from 'node:assert'; diff --git a/examples/claude-agent-sdk-integration.test.ts b/examples/claude-agent-sdk-integration.test.ts new file mode 100644 index 0000000..ab21408 --- /dev/null +++ b/examples/claude-agent-sdk-integration.test.ts @@ -0,0 +1,140 @@ +/** + * E2E test for claude-agent-sdk-integration.ts example + * + * Tests the setup of StackOne tools with Claude Agent SDK. + * + * Note: The Claude Agent SDK spawns a subprocess to run claude-code, which + * requires the ANTHROPIC_API_KEY environment variable and a running claude-code + * installation. This test validates the tool setup and MCP server creation, + * but does not test the actual query execution. + */ + +import { tool, createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk'; +import { z } from 'zod'; +import { StackOneToolSet } from '../src'; + +describe('claude-agent-sdk-integration example e2e', () => { + beforeEach(() => { + vi.stubEnv('STACKONE_API_KEY', 'test-key'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should fetch tools and create Claude Agent SDK tool wrapper', async () => { + const toolset = new StackOneToolSet({ + accountId: 'your-bamboohr-account-id', + baseUrl: 'https://api.stackone.com', + }); + + // Fetch all tools for this account via MCP + const tools = await toolset.fetchTools(); + expect(tools.length).toBeGreaterThan(0); + + // Get a specific tool + const employeeTool = tools.getTool('bamboohr_get_employee'); + expect(employeeTool).toBeDefined(); + assert(employeeTool !== undefined); + + // Create Claude Agent SDK tool from StackOne tool + const getEmployeeTool = tool( + employeeTool.name, + employeeTool.description, + { + id: z.string().describe('The employee ID'), + }, + async (args) => { + const result = await employeeTool.execute(args); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + }; + }, + ); + + expect(getEmployeeTool.name).toBe('bamboohr_get_employee'); + expect(getEmployeeTool.description).toContain('employee'); + expect(getEmployeeTool.inputSchema).toHaveProperty('id'); + expect(typeof getEmployeeTool.handler).toBe('function'); + }); + + it('should create MCP server with StackOne tools', async () => { + const toolset = new StackOneToolSet({ + accountId: 'your-bamboohr-account-id', + baseUrl: 'https://api.stackone.com', + }); + + const tools = await toolset.fetchTools(); + const employeeTool = tools.getTool('bamboohr_get_employee'); + assert(employeeTool !== undefined); + + // Create Claude Agent SDK tool + const getEmployeeTool = tool( + employeeTool.name, + employeeTool.description, + { + id: z.string().describe('The employee ID'), + }, + async (args) => { + const result = await employeeTool.execute(args); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + }; + }, + ); + + // Create an MCP server with the StackOne tool + const mcpServer = createSdkMcpServer({ + name: 'stackone-tools', + version: '1.0.0', + tools: [getEmployeeTool], + }); + + // Verify MCP server was created + expect(mcpServer).toBeDefined(); + expect(mcpServer.name).toBe('stackone-tools'); + expect(mcpServer.instance).toBeDefined(); + }); + + it('should execute tool handler directly', async () => { + const toolset = new StackOneToolSet({ + accountId: 'your-bamboohr-account-id', + baseUrl: 'https://api.stackone.com', + }); + + const tools = await toolset.fetchTools(); + const employeeTool = tools.getTool('bamboohr_get_employee'); + assert(employeeTool !== undefined); + + // Create Claude Agent SDK tool + const getEmployeeTool = tool( + employeeTool.name, + employeeTool.description, + { + id: z.string().describe('The employee ID'), + }, + async (args) => { + const result = await employeeTool.execute(args); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + }; + }, + ); + + // Execute the tool handler directly + const result = await getEmployeeTool.handler( + { id: 'c28xIQaWQ6MzM5MzczMDA2NzMzMzkwNzIwNA' }, + {} as unknown, + ); + + expect(result).toBeDefined(); + expect(result.content).toHaveLength(1); + expect(result.content[0]?.type).toBe('text'); + + // Parse the result text and verify it contains employee data + const textContent = result.content[0]; + assert(textContent?.type === 'text'); + const data = JSON.parse(textContent.text) as unknown; + expect(data).toHaveProperty('data'); + }); +}); diff --git a/examples/claude-agent-sdk-integration.ts b/examples/claude-agent-sdk-integration.ts new file mode 100644 index 0000000..6057dc2 --- /dev/null +++ b/examples/claude-agent-sdk-integration.ts @@ -0,0 +1,88 @@ +/** + * This example shows how to use StackOne tools with Claude Agent SDK. + * + * Claude Agent SDK allows you to create autonomous agents with custom tools + * via MCP (Model Context Protocol) servers. + */ + +import assert from 'node:assert'; +import process from 'node:process'; +import { query, tool, createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk'; +import { z } from 'zod'; +import { StackOneToolSet } from '@stackone/ai'; + +const apiKey = process.env.STACKONE_API_KEY; +if (!apiKey) { + console.error('STACKONE_API_KEY environment variable is required'); + process.exit(1); +} + +// Replace with your actual account ID from StackOne dashboard +const accountId = 'your-bamboohr-account-id'; + +const claudeAgentSdkIntegration = async (): Promise => { + // Initialise StackOne + const toolset = new StackOneToolSet({ + accountId, + baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com', + }); + + // Fetch tools from StackOne + const tools = await toolset.fetchTools(); + + // Get a specific tool + const employeeTool = tools.getTool('bamboohr_get_employee'); + assert(employeeTool !== undefined, 'Expected to find bamboohr_get_employee tool'); + + // Create a Claude Agent SDK tool from the StackOne tool + const getEmployeeTool = tool( + employeeTool.name, + employeeTool.description, + { + id: z.string().describe('The employee ID'), + }, + async (args) => { + const result = await employeeTool.execute(args); + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + }; + }, + ); + + // Create an MCP server with the StackOne tool + const mcpServer = createSdkMcpServer({ + name: 'stackone-tools', + version: '1.0.0', + tools: [getEmployeeTool], + }); + + // Use the Claude Agent SDK query with the custom MCP server + const result = query({ + prompt: 'Get the employee with id: c28xIQaWQ6MzM5MzczMDA2NzMzMzkwNzIwNA', + options: { + model: 'claude-sonnet-4-5-20250929', + mcpServers: { + 'stackone-tools': mcpServer, + }, + // Disable built-in tools, only use our custom tools + tools: [], + maxTurns: 3, + }, + }); + + // Process the stream and collect results + let hasToolCall = false; + for await (const message of result) { + if (message.type === 'assistant') { + for (const block of message.message.content) { + if (block.type === 'tool_use' && block.name === 'bamboohr_get_employee') { + hasToolCall = true; + } + } + } + } + + assert(hasToolCall, 'Expected at least one tool call to bamboohr_get_employee'); +}; + +await claudeAgentSdkIntegration(); diff --git a/examples/fetch-tools-debug.ts b/examples/fetch-tools-debug.ts new file mode 100644 index 0000000..f595e67 --- /dev/null +++ b/examples/fetch-tools-debug.ts @@ -0,0 +1,297 @@ +/** + * Interactive CLI Demo + * + * This example demonstrates how to build an interactive CLI tool using + * @clack/prompts to dynamically discover and execute StackOne tools. + * + * Features: + * - Interactive credential input with environment variable fallback + * - Dynamic tool discovery and selection + * - Spinner feedback during async operations + * + * Run with: + * ```bash + * node --env-files=.env examples/interactive-cli.ts + * ``` + */ + +import process from 'node:process'; +import * as clack from '@clack/prompts'; +import { StackOneToolSet } from '@stackone/ai'; + +/** + * Mask a sensitive value, showing only the first few and last few characters + */ +function maskValue(value: string, visibleStart = 4, visibleEnd = 4): string { + if (value.length <= visibleStart + visibleEnd) { + return '*'.repeat(value.length); + } + const start = value.slice(0, visibleStart); + const end = value.slice(-visibleEnd); + const masked = '*'.repeat(Math.min(value.length - visibleStart - visibleEnd, 8)); + return `${start}${masked}${end}`; +} + +clack.intro('Welcome to StackOne AI Tool Tester'); + +// Get API key +let apiKey: string; +const envApiKey = process.env.STACKONE_API_KEY; +if (envApiKey) { + const apiKeyChoice = await clack.select({ + message: 'StackOne API Key:', + options: [ + { value: 'env', label: 'Use environment variable', hint: maskValue(envApiKey) }, + { value: 'input', label: 'Enter manually' }, + ], + }); + + if (clack.isCancel(apiKeyChoice)) { + clack.cancel('Operation cancelled'); + process.exit(0); + } + + if (apiKeyChoice === 'env') { + apiKey = envApiKey; + } else { + const apiKeyInput = await clack.text({ + message: 'Enter your StackOne API key:', + placeholder: 'v1.us1.xxx...', + validate: (value) => { + if (!value) return 'API key is required'; + }, + }); + + if (clack.isCancel(apiKeyInput)) { + clack.cancel('Operation cancelled'); + process.exit(0); + } + + apiKey = apiKeyInput; + } +} else { + const apiKeyInput = await clack.text({ + message: 'Enter your StackOne API key:', + placeholder: 'v1.us1.xxx...', + validate: (value) => { + if (!value) return 'API key is required'; + }, + }); + + if (clack.isCancel(apiKeyInput)) { + clack.cancel('Operation cancelled'); + process.exit(0); + } + + apiKey = apiKeyInput; +} + +// Get base URL +let baseUrl: string; +const envBaseUrl = process.env.STACKONE_BASE_URL; +if (envBaseUrl) { + const baseUrlChoice = await clack.select({ + message: 'StackOne Base URL:', + options: [ + { value: 'env', label: 'Use environment variable', hint: maskValue(envBaseUrl, 8, 8) }, + { value: 'input', label: 'Enter manually' }, + ], + }); + + if (clack.isCancel(baseUrlChoice)) { + clack.cancel('Operation cancelled'); + process.exit(0); + } + + if (baseUrlChoice === 'env') { + baseUrl = envBaseUrl; + } else { + const baseUrlInput = await clack.text({ + message: 'Enter StackOne Base URL:', + placeholder: 'https://api.stackone.com', + defaultValue: 'https://api.stackone.com', + }); + + if (clack.isCancel(baseUrlInput)) { + clack.cancel('Operation cancelled'); + process.exit(0); + } + + baseUrl = baseUrlInput; + } +} else { + const baseUrlInput = await clack.text({ + message: 'Enter StackOne Base URL (optional):', + placeholder: 'https://api.stackone.com', + defaultValue: 'https://api.stackone.com', + }); + + if (clack.isCancel(baseUrlInput)) { + clack.cancel('Operation cancelled'); + process.exit(0); + } + + baseUrl = baseUrlInput; +} + +// Get account ID +let accountId: string; +const envAccountId = process.env.STACKONE_ACCOUNT_ID; +if (envAccountId) { + const accountIdChoice = await clack.select({ + message: 'StackOne Account ID:', + options: [ + { value: 'env', label: 'Use environment variable', hint: maskValue(envAccountId) }, + { value: 'input', label: 'Enter manually' }, + ], + }); + + if (clack.isCancel(accountIdChoice)) { + clack.cancel('Operation cancelled'); + process.exit(0); + } + + if (accountIdChoice === 'env') { + accountId = envAccountId; + } else { + const accountIdInput = await clack.text({ + message: 'Enter your StackOne Account ID:', + placeholder: 'acc_xxx...', + validate: (value) => { + if (!value) return 'Account ID is required'; + }, + }); + + if (clack.isCancel(accountIdInput)) { + clack.cancel('Operation cancelled'); + process.exit(0); + } + + accountId = accountIdInput as string; + } +} else { + const accountIdInput = await clack.text({ + message: 'Enter your StackOne Account ID:', + placeholder: 'acc_xxx...', + validate: (value) => { + if (!value) return 'Account ID is required'; + }, + }); + + if (clack.isCancel(accountIdInput)) { + clack.cancel('Operation cancelled'); + process.exit(0); + } + + accountId = accountIdInput as string; +} + +// @ts-expect-error Bun global is not in Node.js types +if ((typeof globalThis.Bun as any) !== 'undefined') { + const detailedLog = await clack.confirm({ + message: 'Enable detailed logging? (recommended for Bun.js users)', + }); + + if (clack.isCancel(detailedLog)) { + clack.cancel('Operation cancelled'); + process.exit(0); + } + + if (detailedLog) { + process.env.BUN_CONFIG_VERBOSE_FETCH = 'curl'; + } +} + +const spinner = clack.spinner(); +spinner.start('Initialising StackOne client...'); + +const toolset = new StackOneToolSet({ + apiKey, + baseUrl, + accountId, +}); + +spinner.message('Fetching available tools...'); +const tools = await toolset.fetchTools(); +const allTools = tools.toArray(); +spinner.stop(`Found ${allTools.length} tools`); + +// Select a tool interactively +const selectedToolName = await clack.select({ + message: 'Select a tool to execute:', + options: allTools.map((tool) => ({ + label: tool.description, + value: tool.name, + hint: tool.name, + })), +}); + +if (clack.isCancel(selectedToolName)) { + clack.cancel('Operation cancelled'); + process.exit(0); +} + +const selectedTool = tools.getTool(selectedToolName as string); +if (!selectedTool) { + clack.log.error(`Tool '${selectedToolName}' not found!`); + process.exit(1); +} + +spinner.start(`Executing: ${selectedTool.description}`); +try { + const result = await selectedTool.execute({ + query: { limit: 5 }, + }); + spinner.stop('Execution complete'); + + clack.log.success('Result:'); + + // Display result based on its structure + if (Array.isArray(result)) { + // For array results, use console.table for better readability + if (result.length > 0 && typeof result[0] === 'object') { + console.table(result); + } else { + console.log(result); + } + } else if (result && typeof result === 'object') { + // Check if result has a data array property (common API response pattern) + const data = (result as Record).data; + if (Array.isArray(data) && data.length > 0 && typeof data[0] === 'object') { + console.log('\nData:'); + console.table(data); + + // Show other properties + const otherProps = Object.fromEntries( + Object.entries(result as Record).filter(([key]) => key !== 'data'), + ); + if (Object.keys(otherProps).length > 0) { + console.log('\nMetadata:'); + console.log(JSON.stringify(otherProps, null, 2)); + } + } else { + console.log(JSON.stringify(result, null, 2)); + } + } else { + console.log(result); + } + + clack.outro('Done!'); +} catch (error) { + spinner.stop('Execution failed'); + + if (error instanceof Error) { + clack.log.error(`Error: ${error.message}`); + if (error.cause) { + clack.log.info(`Cause: ${JSON.stringify(error.cause, null, 2)}`); + } + if (error.stack) { + clack.log.info(`Stack trace:\n${error.stack}`); + } + } else { + clack.log.error(`Error: ${JSON.stringify(error, null, 2)}`); + } + + clack.outro('Failed'); + process.exit(1); +} diff --git a/examples/fetch-tools.test.ts b/examples/fetch-tools.test.ts new file mode 100644 index 0000000..23fa210 --- /dev/null +++ b/examples/fetch-tools.test.ts @@ -0,0 +1,102 @@ +/** + * E2E test for fetch-tools.ts example + * + * Tests the complete flow of fetching and filtering tools via MCP. + */ + +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/node'; +import { StackOneToolSet } from '../src'; + +describe('fetch-tools example e2e', () => { + beforeEach(() => { + vi.stubEnv('STACKONE_API_KEY', 'test-key'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should fetch tools, filter by various criteria, and execute a tool', async () => { + // Setup RPC handler for tool execution + server.use( + http.post('https://api.stackone.com/actions/rpc', async ({ request }) => { + const body: unknown = await request.json(); + assert(typeof body === 'object' && body !== null); + const { action } = body as Record; + if (action === 'bamboohr_list_employees') { + return HttpResponse.json({ + data: [ + { id: '1', name: 'Employee 1' }, + { id: '2', name: 'Employee 2' }, + { id: '3', name: 'Employee 3' }, + { id: '4', name: 'Employee 4' }, + { id: '5', name: 'Employee 5' }, + ], + }); + } + return HttpResponse.json({ data: {} }); + }), + ); + + const toolset = new StackOneToolSet({ + baseUrl: 'https://api.stackone.com', + }); + + // Example 1: Fetch all tools (without account filter) + const allTools = await toolset.fetchTools(); + expect(allTools.length).toBeGreaterThan(0); + + // Example 2: Filter by account IDs using setAccounts() + toolset.setAccounts(['your-bamboohr-account-id']); + const toolsByAccounts = await toolset.fetchTools(); + expect(toolsByAccounts.length).toBeGreaterThan(0); + + // Example 3: Filter by account IDs using options + const toolsByAccountsOption = await toolset.fetchTools({ + accountIds: ['your-bamboohr-account-id'], + }); + expect(toolsByAccountsOption.length).toBeGreaterThan(0); + + // Example 4: Filter by providers + const toolsByProviders = await toolset.fetchTools({ + accountIds: ['your-bamboohr-account-id'], + providers: ['bamboohr'], + }); + expect(toolsByProviders.length).toBeGreaterThan(0); + const providerToolNames = toolsByProviders.toArray().map((t) => t.name); + expect( + providerToolNames.every((name) => name.startsWith('bamboohr_') || name.startsWith('meta_')), + ).toBe(true); + + // Example 5: Filter by actions with exact match + const toolsByActions = await toolset.fetchTools({ + accountIds: ['your-bamboohr-account-id'], + actions: ['bamboohr_list_employees', 'bamboohr_create_employee'], + }); + const actionToolNames = toolsByActions.toArray().map((t) => t.name); + expect(actionToolNames).toContain('bamboohr_list_employees'); + expect(actionToolNames).toContain('bamboohr_create_employee'); + + // Example 6: Filter by actions with glob pattern + const toolsByGlobPattern = await toolset.fetchTools({ + accountIds: ['your-bamboohr-account-id'], + actions: ['*_list_employees'], + }); + const globToolNames = toolsByGlobPattern + .toArray() + .filter((t) => !t.name.startsWith('meta_')) + .map((t) => t.name); + expect(globToolNames).toContain('bamboohr_list_employees'); + + // Execute a tool + const tool = toolsByAccounts.getTool('bamboohr_list_employees'); + expect(tool).toBeDefined(); + + const result = await tool!.execute({ + query: { limit: 5 }, + }); + expect(result.data).toBeDefined(); + expect(Array.isArray(result.data)).toBe(true); + }); +}); diff --git a/examples/index.ts b/examples/index.ts deleted file mode 100644 index 01c1bdc..0000000 --- a/examples/index.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * # Installation - * - * ```bash - * # Using npm - * npm install @stackone/ai - * - * # Using yarn - * yarn add @stackone/ai - * - * # Using pnpm - * pnpm add @stackone/ai - * ``` - * - * # Authentication - * - * Set the `STACKONE_API_KEY` environment variable: - * - * ```bash - * export STACKONE_API_KEY= - * ``` - * - * or load from a .env file: - */ - -/** - * # Account IDs - * - * StackOne uses account IDs to identify different integrations. - * Replace the placeholder below with your actual account ID from the StackOne dashboard. - */ - -import process from 'node:process'; - -// Replace with your actual account ID from StackOne dashboard -const accountId = 'your-bamboohr-account-id'; - -/** - * # Quickstart - */ - -import assert from 'node:assert'; -import { StackOneToolSet } from '@stackone/ai'; - -const apiKey = process.env.STACKONE_API_KEY; -if (!apiKey) { - console.error('STACKONE_API_KEY environment variable is required'); - process.exit(1); -} - -const quickstart = async (): Promise => { - const toolset = new StackOneToolSet({ - accountId, - baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com', - }); - - // Fetch all tools for this account via MCP - const tools = await toolset.fetchTools(); - - // Verify we have tools - assert(tools.length > 0, 'Expected to find tools'); - - // Use a specific tool - const employeeTool = tools.getTool('bamboohr_list_employees'); - assert(employeeTool !== undefined, 'Expected to find bamboohr_list_employees tool'); - - // Execute the tool and verify the response - const result = await employeeTool.execute(); - assert(Array.isArray(result.data), 'Expected employees to be an array'); - assert(result.data.length > 0, 'Expected to find at least one employee'); -}; - -// Run the example -await quickstart(); - -/** - * # Next Steps - * - * Check out some more examples: - * - * - [OpenAI Integration](openai-integration.md) - * - [AI SDK Integration](ai-sdk-integration.md) - * - [Fetch Tools](fetch-tools.md) - * - [Meta Tools](meta-tools.md) - */ diff --git a/examples/interactive-cli.ts b/examples/interactive-cli.ts deleted file mode 100644 index cf36482..0000000 --- a/examples/interactive-cli.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * Interactive CLI Demo - * - * This example demonstrates how to build an interactive CLI tool using - * @clack/prompts to dynamically discover and execute StackOne tools. - * - * Features: - * - Interactive credential input with environment variable fallback - * - Dynamic tool discovery and selection - * - Spinner feedback during async operations - * - * Run with: - * ```bash - * npx tsx examples/interactive-cli.ts - * ``` - */ - -import process from 'node:process'; -import * as clack from '@clack/prompts'; -import { StackOneToolSet } from '@stackone/ai'; - -// Enable verbose fetch logging when running with Bun -process.env.BUN_CONFIG_VERBOSE_FETCH = 'curl'; - -clack.intro('Welcome to StackOne AI Tool Tester'); - -// Check if environment variables are available -const hasEnvVars = process.env.STACKONE_API_KEY && process.env.STACKONE_ACCOUNT_ID; - -let apiKey: string; -let baseUrl: string; -let accountId: string; - -if (hasEnvVars) { - const useEnv = await clack.confirm({ - message: 'Use environment variables from .env file?', - }); - - if (clack.isCancel(useEnv)) { - clack.cancel('Operation cancelled'); - process.exit(0); - } - - if (useEnv) { - apiKey = process.env.STACKONE_API_KEY!; - baseUrl = process.env.STACKONE_BASE_URL || 'https://api.stackone.com'; - accountId = process.env.STACKONE_ACCOUNT_ID!; - } else { - const credentials = await promptCredentials(); - apiKey = credentials.apiKey; - baseUrl = credentials.baseUrl; - accountId = credentials.accountId; - } -} else { - const credentials = await promptCredentials(); - apiKey = credentials.apiKey; - baseUrl = credentials.baseUrl; - accountId = credentials.accountId; -} - -async function promptCredentials(): Promise<{ - apiKey: string; - baseUrl: string; - accountId: string; -}> { - const apiKeyInput = await clack.text({ - message: 'Enter your StackOne API key:', - placeholder: 'v1.us1.xxx...', - validate: (value) => { - if (!value) return 'API key is required'; - }, - }); - - if (clack.isCancel(apiKeyInput)) { - clack.cancel('Operation cancelled'); - process.exit(0); - } - - const baseUrlInput = await clack.text({ - message: 'Enter StackOne Base URL (optional):', - placeholder: 'https://api.stackone.com', - defaultValue: 'https://api.stackone.com', - }); - - if (clack.isCancel(baseUrlInput)) { - clack.cancel('Operation cancelled'); - process.exit(0); - } - - const accountIdInput = await clack.text({ - message: 'Enter your StackOne Account ID:', - placeholder: 'acc_xxx...', - validate: (value) => { - if (!value) return 'Account ID is required'; - }, - }); - - if (clack.isCancel(accountIdInput)) { - clack.cancel('Operation cancelled'); - process.exit(0); - } - - return { - apiKey: apiKeyInput as string, - baseUrl: baseUrlInput as string, - accountId: accountIdInput as string, - }; -} - -const spinner = clack.spinner(); -spinner.start('Initialising StackOne client...'); - -const toolset = new StackOneToolSet({ - apiKey, - baseUrl, - accountId, -}); - -spinner.message('Fetching available tools...'); -const tools = await toolset.fetchTools(); -const allTools = tools.toArray(); -spinner.stop(`Found ${allTools.length} tools`); - -// Select a tool interactively -const selectedToolName = await clack.select({ - message: 'Select a tool to execute:', - options: allTools.map((tool) => ({ - label: tool.description, - value: tool.name, - hint: tool.name, - })), -}); - -if (clack.isCancel(selectedToolName)) { - clack.cancel('Operation cancelled'); - process.exit(0); -} - -const selectedTool = tools.getTool(selectedToolName as string); -if (!selectedTool) { - clack.log.error(`Tool '${selectedToolName}' not found!`); - process.exit(1); -} - -spinner.start(`Executing: ${selectedTool.description}`); -try { - const result = await selectedTool.execute({ - query: { limit: 5 }, - }); - spinner.stop('Execution complete'); - - clack.log.success('Result:'); - console.log(JSON.stringify(result, null, 2)); - clack.outro('Done!'); -} catch (error) { - spinner.stop('Execution failed'); - clack.log.error(error instanceof Error ? error.message : String(error)); - clack.outro('Failed'); - process.exit(1); -} diff --git a/examples/openai-integration.test.ts b/examples/openai-integration.test.ts new file mode 100644 index 0000000..fb59e1f --- /dev/null +++ b/examples/openai-integration.test.ts @@ -0,0 +1,72 @@ +/** + * E2E test for openai-integration.ts example + * + * Tests the complete flow of using StackOne tools with OpenAI Chat Completions API. + */ + +import OpenAI from 'openai'; +import { StackOneToolSet } from '../src'; + +describe('openai-integration example e2e', () => { + beforeEach(() => { + vi.stubEnv('STACKONE_API_KEY', 'test-key'); + vi.stubEnv('OPENAI_API_KEY', 'test-openai-key'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should fetch tools, convert to OpenAI format, and create chat completion with tool calls', async () => { + const toolset = new StackOneToolSet({ + accountId: 'your-bamboohr-account-id', + baseUrl: 'https://api.stackone.com', + }); + + // Fetch all tools for this account via MCP + const tools = await toolset.fetchTools(); + const openAITools = tools.toOpenAI(); + + // Verify tools are in OpenAI format + expect(Array.isArray(openAITools)).toBe(true); + expect(openAITools.length).toBeGreaterThan(0); + expect(openAITools[0]).toHaveProperty('type', 'function'); + expect(openAITools[0]).toHaveProperty('function'); + + // Initialise OpenAI client + const openai = new OpenAI(); + + // Create a chat completion with tool calls + const response = await openai.chat.completions.create({ + model: 'gpt-5', + messages: [ + { + role: 'system', + content: 'You are a helpful assistant that can access BambooHR information.', + }, + { + role: 'user', + content: + 'What is the employee with id: c28xIQaWQ6MzM5MzczMDA2NzMzMzkwNzIwNA phone number?', + }, + ], + tools: openAITools, + }); + + // Verify the response contains tool calls + expect(response.choices.length).toBeGreaterThan(0); + + const choice = response.choices[0]; + expect(choice.message.tool_calls).toBeDefined(); + expect(choice.message.tool_calls!.length).toBeGreaterThan(0); + + const toolCall = choice.message.tool_calls![0]; + assert(toolCall.type === 'function'); + expect(toolCall.function.name).toBe('bamboohr_get_employee'); + + // Parse the arguments to verify they contain the expected fields + const args: unknown = JSON.parse(toolCall.function.arguments); + assert(typeof args === 'object' && args !== null && 'id' in args); + expect(args.id).toBe('c28xIQaWQ6MzM5MzczMDA2NzMzMzkwNzIwNA'); + }); +}); diff --git a/examples/openai-responses-integration.test.ts b/examples/openai-responses-integration.test.ts new file mode 100644 index 0000000..77269d5 --- /dev/null +++ b/examples/openai-responses-integration.test.ts @@ -0,0 +1,64 @@ +/** + * E2E test for openai-responses-integration.ts example + * + * Tests the complete flow of using StackOne tools with OpenAI Responses API. + */ + +import OpenAI from 'openai'; +import { StackOneToolSet } from '../src'; + +describe('openai-responses-integration example e2e', () => { + beforeEach(() => { + vi.stubEnv('STACKONE_API_KEY', 'test-key'); + vi.stubEnv('OPENAI_API_KEY', 'test-openai-key'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should fetch tools, convert to OpenAI Responses format, and create response with tool calls', async () => { + const toolset = new StackOneToolSet({ + accountId: 'your-stackone-account-id', + }); + + // Fetch tools via MCP with action filter + const tools = await toolset.fetchTools({ + actions: ['*_list_*'], + }); + const openAIResponsesTools = tools.toOpenAIResponses(); + + // Verify tools are in OpenAI Responses format + expect(Array.isArray(openAIResponsesTools)).toBe(true); + expect(openAIResponsesTools.length).toBeGreaterThan(0); + + // Initialise OpenAI client + const openai = new OpenAI(); + + // Create a response with tool calls using the Responses API + const response = await openai.responses.create({ + model: 'gpt-5', + instructions: 'You are a helpful assistant that can access various tools.', + input: 'What is the employee with id: c28xIQaWQ6MzM5MzczMDA2NzMzMzkwNzIwNA phone number?', + tools: openAIResponsesTools, + }); + + // Verify the response contains expected data + expect(response.id).toBeDefined(); + expect(response.model).toBeDefined(); + + // Check if the model made any tool calls + const toolCalls = response.output.filter( + (item): item is OpenAI.Responses.ResponseFunctionToolCall => item.type === 'function_call', + ); + + expect(toolCalls.length).toBeGreaterThan(0); + + const toolCall = toolCalls[0]; + expect(toolCall.name).toBe('bamboohr_get_employee'); + + // Parse the arguments to verify they contain the expected fields + const args = JSON.parse(toolCall.arguments); + expect(args.id).toBe('c28xIQaWQ6MzM5MzczMDA2NzMzMzkwNzIwNA'); + }); +}); diff --git a/examples/package.json b/examples/package.json index 5ea8664..f26ee54 100644 --- a/examples/package.json +++ b/examples/package.json @@ -3,21 +3,20 @@ "version": "0.0.0", "private": true, "type": "module", - "scripts": { - "lint": "oxlint --max-warnings=0 --type-aware --type-check", - "format": "oxlint --max-warnings=0 --type-aware --type-check --fix" - }, "dependencies": { - "@stackone/ai": "workspace:*" - }, - "devDependencies": { "@ai-sdk/openai": "catalog:dev", + "@anthropic-ai/claude-agent-sdk": "catalog:examples", "@anthropic-ai/sdk": "catalog:peer", "@clack/prompts": "catalog:dev", - "@types/node": "catalog:dev", + "@stackone/ai": "workspace:*", + "@tanstack/ai": "catalog:examples", + "@tanstack/ai-openai": "catalog:examples", "ai": "catalog:peer", - "openai": "catalog:peer", - "zod": "catalog:dev" + "openai": "catalog:peer" + }, + "devDependencies": { + "@types/node": "catalog:dev", + "msw": "catalog:dev" }, "devEngines": { "runtime": [ diff --git a/examples/sample-document.txt b/examples/sample-document.txt deleted file mode 100644 index 3a0f1d0..0000000 --- a/examples/sample-document.txt +++ /dev/null @@ -1 +0,0 @@ -This is an experimental document handling test file. \ No newline at end of file diff --git a/examples/tanstack-ai-integration.test.ts b/examples/tanstack-ai-integration.test.ts new file mode 100644 index 0000000..17b1fe1 --- /dev/null +++ b/examples/tanstack-ai-integration.test.ts @@ -0,0 +1,84 @@ +/** + * E2E test for tanstack-ai-integration.ts example + * + * Tests the complete flow of using StackOne tools with TanStack AI. + * + * Note: TanStack AI requires Zod schemas for tool input validation. + * This test validates tool setup and schema conversion, but the actual + * chat() call requires Zod schemas which are not directly exposed by + * StackOne tools. + */ + +import { StackOneToolSet } from '../src'; + +describe('tanstack-ai-integration example e2e', () => { + beforeEach(() => { + vi.stubEnv('STACKONE_API_KEY', 'test-key'); + vi.stubEnv('OPENAI_API_KEY', 'test-openai-key'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should fetch tools and convert to TanStack AI format', async () => { + const toolset = new StackOneToolSet({ + accountId: 'your-bamboohr-account-id', + baseUrl: 'https://api.stackone.com', + }); + + // Fetch all tools for this account via MCP + const tools = await toolset.fetchTools(); + expect(tools.length).toBeGreaterThan(0); + + // Get a specific tool + const employeeTool = tools.getTool('bamboohr_get_employee'); + expect(employeeTool).toBeDefined(); + + // Create TanStack AI compatible tool wrapper + // Use toJsonSchema() to get the parameter schema in JSON Schema format + const getEmployeeTool = { + name: employeeTool!.name, + description: employeeTool!.description, + inputSchema: employeeTool!.toJsonSchema(), + execute: async (args: Record) => { + return employeeTool!.execute(args); + }, + }; + + expect(getEmployeeTool.name).toBe('bamboohr_get_employee'); + expect(getEmployeeTool.description).toContain('employee'); + expect(getEmployeeTool.inputSchema).toBeDefined(); + expect(getEmployeeTool.inputSchema.type).toBe('object'); + expect(typeof getEmployeeTool.execute).toBe('function'); + }); + + it('should execute tool directly', async () => { + const toolset = new StackOneToolSet({ + accountId: 'your-bamboohr-account-id', + baseUrl: 'https://api.stackone.com', + }); + + const tools = await toolset.fetchTools(); + const employeeTool = tools.getTool('bamboohr_get_employee'); + assert(employeeTool !== undefined, 'Expected to find bamboohr_get_employee tool'); + + // Create TanStack AI compatible tool wrapper + const getEmployeeTool = { + name: employeeTool.name, + description: employeeTool.description, + inputSchema: employeeTool.toJsonSchema(), + execute: async (args: Record) => { + return employeeTool.execute(args); + }, + }; + + // Execute the tool directly to verify it works + const result = await getEmployeeTool.execute({ + id: 'c28xIQaWQ6MzM5MzczMDA2NzMzMzkwNzIwNA', + }); + + expect(result).toBeDefined(); + expect(result).toHaveProperty('data'); + }); +}); diff --git a/examples/tanstack-ai-integration.ts b/examples/tanstack-ai-integration.ts new file mode 100644 index 0000000..b81e7f0 --- /dev/null +++ b/examples/tanstack-ai-integration.ts @@ -0,0 +1,83 @@ +/** + * This example shows how to use StackOne tools with TanStack AI. + * + * TanStack AI requires Zod schemas for tool input validation. + * This example demonstrates how to wrap StackOne tools for use with TanStack AI + * by creating Zod schemas that match the tool's JSON Schema. + */ + +import assert from 'node:assert'; +import process from 'node:process'; +import { chat } from '@tanstack/ai'; +import { openai } from '@tanstack/ai-openai'; +import { z } from 'zod'; +import { StackOneToolSet } from '@stackone/ai'; + +const apiKey = process.env.STACKONE_API_KEY; +if (!apiKey) { + console.error('STACKONE_API_KEY environment variable is required'); + process.exit(1); +} + +// Replace with your actual account ID from StackOne dashboard +const accountId = 'your-bamboohr-account-id'; + +const tanstackAiIntegration = async (): Promise => { + // Initialise StackOne + const toolset = new StackOneToolSet({ + accountId, + baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com', + }); + + // Fetch tools from StackOne + const tools = await toolset.fetchTools(); + + // Get a specific tool and create a TanStack AI compatible tool + const employeeTool = tools.getTool('bamboohr_get_employee'); + assert(employeeTool !== undefined, 'Expected to find bamboohr_get_employee tool'); + + // Create a TanStack AI server tool from the StackOne tool + // TanStack AI requires Zod schemas, so we create one that matches the tool's parameters + const getEmployeeTool = { + name: employeeTool.name, + description: employeeTool.description, + // TanStack AI requires Zod schema for input validation + inputSchema: z.object({ + id: z.string().describe('The employee ID'), + }), + execute: async (args: { id: string }) => { + return employeeTool.execute(args); + }, + }; + + // Use TanStack AI chat with the tool + // The adapter reads OPENAI_API_KEY from the environment automatically + const adapter = openai(); + const stream = chat({ + adapter, + model: 'gpt-4o', + messages: [ + { + role: 'user', + content: 'Get the employee with id: c28xIQaWQ6MzM5MzczMDA2NzMzMzkwNzIwNA', + }, + ], + tools: [getEmployeeTool], + }); + + // Process the stream + let hasToolCall = false; + for await (const chunk of stream) { + if (chunk.type === 'tool_call') { + hasToolCall = true; + assert( + chunk.toolCall.function.name === 'bamboohr_get_employee', + 'Expected tool call to be bamboohr_get_employee', + ); + } + } + + assert(hasToolCall, 'Expected at least one tool call'); +}; + +await tanstackAiIntegration(); diff --git a/examples/tsconfig.json b/examples/tsconfig.json index 2f1f9a6..72ce66a 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -9,7 +9,7 @@ "module": "ESNext", "moduleResolution": "bundler", "lib": ["es2022"], - "types": ["node"], + "types": ["node", "vitest/globals"], }, "include": ["**/*.ts"], "exclude": ["node_modules"], diff --git a/knip.config.ts b/knip.config.ts index 5008c57..9963d45 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -7,13 +7,13 @@ export default { project: ['src/**/*.ts', 'mocks/**/*.ts'], }, examples: { - entry: ['*.ts'], + entry: ['*.ts', '*.test.ts'], project: ['*.ts'], }, }, ignore: ['**/*.test.ts', '**/*.spec.ts', '**/*.test-d.ts'], ignoreBinaries: ['only-allow'], - ignoreDependencies: ['@typescript/native-preview', 'lefthook'], + ignoreDependencies: ['@typescript/native-preview'], rules: { optionalPeerDependencies: 'off', devDependencies: 'warn', diff --git a/mocks/handlers.mcp.ts b/mocks/handlers.mcp.ts index 1bb0541..d091ce9 100644 --- a/mocks/handlers.mcp.ts +++ b/mocks/handlers.mcp.ts @@ -1,5 +1,11 @@ import { http } from 'msw'; -import { accountMcpTools, createMcpApp, defaultMcpTools, mixedProviderTools } from './mcp-server'; +import { + accountMcpTools, + createMcpApp, + defaultMcpTools, + exampleBamboohrTools, + mixedProviderTools, +} from './mcp-server'; // Create MCP apps for testing const defaultMcpApp = createMcpApp({ @@ -10,6 +16,9 @@ const defaultMcpApp = createMcpApp({ acc3: accountMcpTools.acc3, 'test-account': accountMcpTools['test-account'], mixed: mixedProviderTools, + // For examples testing + 'your-bamboohr-account-id': exampleBamboohrTools, + 'your-stackone-account-id': exampleBamboohrTools, }, }); diff --git a/mocks/handlers.openai.ts b/mocks/handlers.openai.ts index 002391e..6f004c2 100644 --- a/mocks/handlers.openai.ts +++ b/mocks/handlers.openai.ts @@ -41,6 +41,30 @@ export const openaiHandlers = [ }); } + // For openai-responses-integration.ts + if (hasTools && userMessage.includes('c28xIQaWQ6MzM5MzczMDA2NzMzMzkwNzIwNA')) { + return HttpResponse.json({ + id: 'resp_mock_responses', + object: 'response', + created_at: Date.now(), + model: 'gpt-5', + status: 'completed', + output: [ + { + type: 'function_call', + id: 'call_mock_get', + call_id: 'call_mock_get', + name: 'bamboohr_get_employee', + arguments: JSON.stringify({ + id: 'c28xIQaWQ6MzM5MzczMDA2NzMzMzkwNzIwNA', + }), + status: 'completed', + }, + ], + usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 }, + }); + } + // For human-in-the-loop.ts if (hasTools && userMessage.includes('Create a new employee')) { return HttpResponse.json({ diff --git a/mocks/handlers.utils.ts b/mocks/handlers.utils.ts index e1b8263..da47386 100644 --- a/mocks/handlers.utils.ts +++ b/mocks/handlers.utils.ts @@ -2,6 +2,8 @@ * Helper to extract text content from OpenAI responses API input */ export const extractTextFromInput = (input: unknown): string => { + // Handle string input directly (OpenAI Responses API can accept plain strings) + if (typeof input === 'string') return input; if (!Array.isArray(input)) return ''; for (const item of input) { if (typeof item === 'object' && item !== null) { diff --git a/mocks/mcp-server.ts b/mocks/mcp-server.ts index 6c7d62e..24a5325 100644 --- a/mocks/mcp-server.ts +++ b/mocks/mcp-server.ts @@ -183,6 +183,52 @@ export const accountMcpTools = { ], } as const satisfies Record; +/** Tools for the quickstart and example tests */ +export const exampleBamboohrTools = [ + { + name: 'bamboohr_list_employees', + description: 'List all employees from BambooHR', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'object', + properties: { + limit: { type: 'number', description: 'Limit the number of results' }, + }, + }, + }, + }, + }, + { + name: 'bamboohr_get_employee', + description: 'Get a single employee by ID from BambooHR', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'The employee ID' }, + fields: { type: 'string', description: 'Fields to retrieve' }, + }, + required: ['id'], + }, + }, + { + name: 'bamboohr_create_employee', + description: 'Create a new employee in BambooHR', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Employee name' }, + personal_email: { type: 'string', description: 'Employee email' }, + department: { type: 'string', description: 'Department name' }, + start_date: { type: 'string', description: 'Start date' }, + hire_date: { type: 'string', description: 'Hire date' }, + }, + required: ['name'], + }, + }, +] as const satisfies McpToolDefinition[]; + export const mixedProviderTools = [ { name: 'hibob_list_employees', diff --git a/package.json b/package.json index 40b75ca..99464bb 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "src", "dist", "README.md", - "LICENSE" + "LICENSE", + "examples/*.ts", + "!example/*.test.ts" ], "scripts": { "build": "tsdown", @@ -23,11 +25,9 @@ "format:oxfmt": "oxfmt --no-error-on-unmatched-pattern .", "format:oxlint": "oxlint --max-warnings=0 --type-aware --type-check --fix", "format:knip": "knip --fix --no-exit-code", - "format:submodule": "pnpm --parallel -r --aggregate-output format", "lint": "pnpm --aggregate-output run '/^lint:/'", "lint:oxfmt": "oxfmt --no-error-on-unmatched-pattern --check .", "lint:oxlint": "oxlint --max-warnings=0 --type-aware --type-check", - "lint:submodule": "pnpm --parallel -r --aggregate-output lint", "lint:knip": "knip", "preinstall": "npx only-allow pnpm", "prepack": "npm pkg delete scripts.preinstall && pnpm run build", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7cbcfb..afdd528 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,16 @@ catalogs: zod: specifier: ^4.1.13 version: 4.1.13 + examples: + '@anthropic-ai/claude-agent-sdk': + specifier: ^0.1.67 + version: 0.1.67 + '@tanstack/ai': + specifier: ^0.0.3 + version: 0.0.3 + '@tanstack/ai-openai': + specifier: ^0.0.3 + version: 0.0.3 peer: '@anthropic-ai/sdk': specifier: ^0.52.0 @@ -175,34 +185,43 @@ importers: examples: dependencies: - '@stackone/ai': - specifier: workspace:* - version: link:.. - devDependencies: '@ai-sdk/openai': specifier: catalog:dev version: 2.0.80(zod@4.1.13) + '@anthropic-ai/claude-agent-sdk': + specifier: catalog:examples + version: 0.1.67(zod@4.1.13) '@anthropic-ai/sdk': specifier: catalog:peer version: 0.52.0 '@clack/prompts': specifier: catalog:dev version: 0.11.0 - '@types/node': - specifier: catalog:dev - version: 22.19.1 + '@stackone/ai': + specifier: workspace:* + version: link:.. + '@tanstack/ai': + specifier: catalog:examples + version: 0.0.3(@alcyone-labs/zod-to-json-schema@4.0.10(zod@4.1.13))(zod@4.1.13) + '@tanstack/ai-openai': + specifier: catalog:examples + version: 0.0.3(@tanstack/ai@0.0.3(@alcyone-labs/zod-to-json-schema@4.0.10(zod@4.1.13))(zod@4.1.13))(zod@4.1.13) ai: specifier: catalog:peer version: 5.0.108(zod@4.1.13) - node: - specifier: runtime:^24.11.0 - version: runtime:24.12.0 openai: specifier: catalog:peer version: 6.9.1(zod@4.1.13) - zod: + devDependencies: + '@types/node': specifier: catalog:dev - version: 4.1.13 + version: 22.19.1 + msw: + specifier: catalog:dev + version: 2.12.3(@types/node@22.19.1)(typescript@5.9.3) + node: + specifier: runtime:^24.11.0 + version: runtime:24.12.0 packages: '@ai-sdk/gateway@2.0.18': @@ -239,6 +258,23 @@ packages: } engines: { node: '>=18' } + '@alcyone-labs/zod-to-json-schema@4.0.10': + resolution: + { + integrity: sha512-TFsSpAPToqmqmT85SGHXuxoCwEeK9zUDvn512O9aBVvWRhSuy+VvAXZkifzsdllD3ncF0ZjUrf4MpBwIEixdWQ==, + } + peerDependencies: + zod: ^4.0.5 + + '@anthropic-ai/claude-agent-sdk@0.1.67': + resolution: + { + integrity: sha512-SPeMOfBeQ4Q6BcTRGRyMzaSEzKja3w8giZn6xboab02rPly5KQmgDK0wNerUntPe+xyw7c01xdu5K/pjZXq0dw==, + } + engines: { node: '>=18.0.0' } + peerDependencies: + zod: ^3.24.1 + '@anthropic-ai/sdk@0.52.0': resolution: { @@ -796,6 +832,144 @@ packages: '@modelcontextprotocol/sdk': ^1.12.0 hono: '>=4.0.0' + '@img/sharp-darwin-arm64@0.33.5': + resolution: + { + integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.33.5': + resolution: + { + integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.0.4': + resolution: + { + integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==, + } + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.0.4': + resolution: + { + integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==, + } + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.0.4': + resolution: + { + integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==, + } + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.0.5': + resolution: + { + integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==, + } + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.0.4': + resolution: + { + integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==, + } + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + resolution: + { + integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==, + } + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + resolution: + { + integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==, + } + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.33.5': + resolution: + { + integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.33.5': + resolution: + { + integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.33.5': + resolution: + { + integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.33.5': + resolution: + { + integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.33.5': + resolution: + { + integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-win32-x64@0.33.5': + resolution: + { + integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [x64] + os: [win32] + '@inquirer/ansi@1.0.2': resolution: { @@ -1646,6 +1820,31 @@ packages: integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==, } + '@tanstack/ai-openai@0.0.3': + resolution: + { + integrity: sha512-JyV5KMvaUIkS/9mt8zdu+8Sl0+/btbwrsreuFXftbrL8H+ysvbmFW3KwD2eUdTBwNPv2szUn5su17X1yt1CphQ==, + } + peerDependencies: + '@tanstack/ai': 0.0.3 + + '@tanstack/ai@0.0.3': + resolution: + { + integrity: sha512-zwSl0obT/fkUZocI22xClGNg66yWiRw3d3BKz7F5/V8E+JWFZk2Gh4P7V9wrr0uUEjsZlt8u7qNDgBzwU9uu9g==, + } + engines: { node: '>=18' } + peerDependencies: + '@alcyone-labs/zod-to-json-schema': ^4.0.0 + zod: ^3.0.0 || ^4.0.0 + + '@tanstack/devtools-event-client@0.4.0': + resolution: + { + integrity: sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw==, + } + engines: { node: '>=18' } + '@tybys/wasm-util@0.10.1': resolution: { @@ -2968,6 +3167,12 @@ packages: } engines: { node: '>= 0.8' } + partial-json@0.1.7: + resolution: + { + integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==, + } + path-key@3.1.1: resolution: { @@ -3737,6 +3942,23 @@ snapshots: dependencies: json-schema: 0.4.0 + '@alcyone-labs/zod-to-json-schema@4.0.10(zod@4.1.13)': + dependencies: + zod: 4.1.13 + + '@anthropic-ai/claude-agent-sdk@0.1.67(zod@4.1.13)': + dependencies: + zod: 4.1.13 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-linuxmusl-arm64': 0.33.5 + '@img/sharp-linuxmusl-x64': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + '@anthropic-ai/sdk@0.52.0': {} '@babel/generator@7.28.5': @@ -3950,6 +4172,65 @@ snapshots: '@modelcontextprotocol/sdk': 1.24.3(zod@4.1.13) hono: 4.10.7 + '@img/sharp-darwin-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 + optional: true + + '@img/sharp-darwin-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.0.5': + optional: true + + '@img/sharp-libvips-linux-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + optional: true + + '@img/sharp-linux-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 + optional: true + + '@img/sharp-linux-arm@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 + optional: true + + '@img/sharp-linux-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + optional: true + + '@img/sharp-win32-x64@0.33.5': + optional: true + '@inquirer/ansi@1.0.2': {} '@inquirer/confirm@5.1.21(@types/node@22.19.1)': @@ -4304,6 +4585,23 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@tanstack/ai-openai@0.0.3(@tanstack/ai@0.0.3(@alcyone-labs/zod-to-json-schema@4.0.10(zod@4.1.13))(zod@4.1.13))(zod@4.1.13)': + dependencies: + '@tanstack/ai': 0.0.3(@alcyone-labs/zod-to-json-schema@4.0.10(zod@4.1.13))(zod@4.1.13) + openai: 6.9.1(zod@4.1.13) + transitivePeerDependencies: + - ws + - zod + + '@tanstack/ai@0.0.3(@alcyone-labs/zod-to-json-schema@4.0.10(zod@4.1.13))(zod@4.1.13)': + dependencies: + '@alcyone-labs/zod-to-json-schema': 4.0.10(zod@4.1.13) + '@tanstack/devtools-event-client': 0.4.0 + partial-json: 0.1.7 + zod: 4.1.13 + + '@tanstack/devtools-event-client@0.4.0': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -5068,6 +5366,8 @@ snapshots: parseurl@1.3.3: {} + partial-json@0.1.7: {} + path-key@3.1.1: {} path-to-regexp@6.3.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5aa9cf9..f13557b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -27,6 +27,10 @@ catalogs: unplugin-unused: ^0.5.4 vitest: ^4.0.15 zod: ^4.1.13 + examples: + '@anthropic-ai/claude-agent-sdk': ^0.1.67 + '@tanstack/ai': ^0.0.3 + '@tanstack/ai-openai': ^0.0.3 peer: '@anthropic-ai/sdk': ^0.52.0 ai: ^5.0.108 @@ -40,6 +44,11 @@ enablePrePostScripts: true minimumReleaseAge: 1440 +minimumReleaseAgeExclude: + - '@anthropic-ai/claude-agent-sdk' + - '@tanstack/ai' + - '@tanstack/ai-openai' + onlyBuiltDependencies: - esbuild - lefthook diff --git a/src/tool.ts b/src/tool.ts index 8bdc110..154d397 100644 --- a/src/tool.ts +++ b/src/tool.ts @@ -265,7 +265,7 @@ export class BaseTool { return { [this.name]: toolDefinition, - } as AISDKToolResult; + } satisfies AISDKToolResult; } } diff --git a/vitest.config.ts b/vitest.config.ts index 69d2bb2..7177643 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,10 +5,11 @@ export default defineConfig({ watch: false, globals: true, testTimeout: 30000, + setupFiles: ['./vitest.setup.ts'], coverage: { provider: 'v8', reporter: ['text', 'json', 'json-summary', 'html'], - include: ['src/**/*.ts', 'examples/**/*.ts'], + include: ['src/**/*.ts'], exclude: ['**/*.test.ts', '**/*.test-d.ts'], }, projects: [ @@ -19,7 +20,6 @@ export default defineConfig({ root: '.', include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'], exclude: ['node_modules', 'dist', 'examples'], - setupFiles: ['./vitest.setup.ts'], typecheck: { enabled: true, include: ['src/**/*.test.ts', 'src/**/*.test-d.ts'],