// Node 20.19+. No packages needed. Credentials and tokens remain in memory. import { fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; const TIMEOUT_MS = 15000; const MAX_BYTES = 1048576; class ClientError extends Error {} function configuration(env = process.env) { let url; try { url = new URL(env.TASKVEGAS_URL || 'https://taskvegas.si'); } catch { throw new ClientError('TASKVEGAS_URL must be a bare HTTPS origin.'); } const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || url.port === '0' || url.username || url.password || url.search || url.hash || url.pathname !== '/') throw new ClientError('Use a bare HTTPS origin, or HTTP on localhost for development.'); const clientId = env.TASKVEGAS_CLIENT_ID; const clientSecret = env.TASKVEGAS_CLIENT_SECRET; if (![clientId, clientSecret].every(value => typeof value === 'string' && value.length > 0 && value.length <= 4096 && !/[\s\u0000-\u001f\u007f]/.test(value)) || clientId.includes(':')) throw new ClientError('Set a valid TASKVEGAS_CLIENT_ID and TASKVEGAS_CLIENT_SECRET in your environment.'); return { origin: url.origin, clientId, clientSecret }; } async function jsonResponse(response, maxBytes = MAX_BYTES) { if (!/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') || '')) { await response.body?.cancel(); throw new ClientError('TaskVegas returned an unexpected response type.'); } const chunks = []; let count = 0; for await (const chunk of response.body) { count += chunk.length; if (count > maxBytes) { await response.body.cancel().catch(() => {}); throw new ClientError('TaskVegas response exceeded the client size limit.'); } chunks.push(Buffer.from(chunk)); } try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))); } catch { throw new ClientError('TaskVegas returned invalid JSON.'); } } function statusError(status) { const reasons = { 401: 'Credentials or token are expired, revoked, or invalid.', 403: 'This agent does not have the exact tool permission.', 424: 'A required workspace tool is not ready.', 429: 'A usage limit was reached; do not retry automatically.' }; return new ClientError(`TaskVegas request failed (HTTP ${status}). ${reasons[status] || 'Check the service and request before trying again.'}`); } export class TaskVegasClient { constructor(env = process.env) { this.config = configuration(env); this.resource = `${this.config.origin}/api/v1`; this.access = null; } async token(force = false) { if (!force && this.access && this.access.expiresAt > Date.now() + 30000) return this.access.token; this.access = null; const response = await fetch(`${this.config.origin}/token`, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(TIMEOUT_MS), headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', Authorization: `Basic ${Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64')}` }, body: new URLSearchParams({ grant_type: 'client_credentials', resource: this.resource }) }); if (!response.ok) { await response.body?.cancel(); throw statusError(response.status); } const value = await jsonResponse(response, 16384); if (typeof value.access_token !== 'string' || !/^[A-Za-z0-9_.~-]{1,1024}$/.test(value.access_token) || !/^Bearer$/i.test(value.token_type) || !Number.isInteger(value.expires_in) || value.expires_in < 1 || value.expires_in > 3600) throw new ClientError('TaskVegas returned an invalid token response.'); this.access = { token: value.access_token, expiresAt: Date.now() + value.expires_in * 1000 }; return this.access.token; } async request(path, { method = 'GET', body } = {}) { if (!/^\/api\/v1\/(?:tools|tools\/taskvegas_search_docs\/execute)$/.test(path)) throw new ClientError('This example supports only its fixed tool endpoints.'); const send = async token => fetch(`${this.config.origin}${path}`, { method, redirect: 'error', signal: AbortSignal.timeout(TIMEOUT_MS), headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) }); let response = await send(await this.token()); // Discovery is a read: renew once after a rejected token. Execution is never retried. if (response.status === 401 && method === 'GET') { await response.body?.cancel(); response = await send(await this.token(true)); } if (!response.ok) { if (response.status === 401) this.access = null; await response.body?.cancel(); throw statusError(response.status); } return jsonResponse(response); } async searchDocumentation(query = 'diagrams') { const catalog = await this.request('/api/v1/tools'); if (catalog.resource !== this.resource || !Array.isArray(catalog.tools)) throw new ClientError('Tool discovery returned the wrong resource or shape.'); const tool = catalog.tools.find(item => item.name === 'taskvegas_search_docs'); if (!tool || tool.readOnly !== true || tool.paid !== false) throw new ClientError('Grant the free taskvegas_search_docs tool before running this example.'); if (typeof query !== 'string' || !query.trim() || query.length > 300) throw new ClientError('Use a documentation query of 1–300 characters.'); const value = await this.request('/api/v1/tools/taskvegas_search_docs/execute', { method: 'POST', body: { input: { query, limit: 3 } } }); if (!value || !Object.hasOwn(value, 'result')) throw new ClientError('Tool execution returned an incomplete response.'); return value.result; } } if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { try { const result = await new TaskVegasClient().searchDocumentation(); // Deliberately print only a count, never credentials, tokens or raw server errors. console.log(`Documentation search completed: ${Array.isArray(result?.items) ? result.items.length : 0} result(s).`); } catch (error) { console.error(error instanceof ClientError ? error.message : 'Agent client failed. Check configuration, connectivity and service status. The tool request was not retried.'); process.exitCode = 1; } }