import { tool } from '@strands-agents/sdk' import { z } from 'zod' // ──────────────────────────────────────────────────────────────────── // use_github — GraphQL-first GitHub tool (mirrors devduck's use_github) // ──────────────────────────────────────────────────────────────────── // One tool, any GraphQL query or mutation. REST helpers below are thin // wrappers that build GraphQL under the hood so there's exactly one // auth + transport path to maintain. function getToken(): string | null { try { const raw = localStorage.getItem('careless-v2-settings') return raw ? JSON.parse(raw).githubToken : null } catch { return null } } interface GraphQLResult { status: 'success' | 'error' data?: unknown errors?: unknown rate_limit?: { remaining: number; limit: number; reset_at?: string } message?: string } async function graphql( query: string, variables?: Record, ): Promise { const token = getToken() if (!token) { return { status: 'error', message: 'GITHUB_TOKEN missing. Set githubToken in Settings (Settings > Credentials).', } } try { const res = await fetch('https://api.github.com/graphql', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/vnd.github+json', }, body: JSON.stringify({ query, variables: variables || {} }), }) const rateRemaining = res.headers.get('x-ratelimit-remaining') const rateLimit = res.headers.get('x-ratelimit-limit') const rateReset = res.headers.get('x-ratelimit-reset') const rate_limit = rateRemaining ? { remaining: Number(rateRemaining), limit: Number(rateLimit || 0), reset_at: rateReset ? new Date(Number(rateReset) * 1000).toISOString() : undefined, } : undefined if (!res.ok) { const text = await res.text().catch(() => '') return { status: 'error', message: `HTTP ${res.status}: ${text.slice(0, 500)}`, rate_limit, } } const body = await res.json() if (body.errors) { return { status: 'error', errors: body.errors, rate_limit } } return { status: 'success', data: body.data, rate_limit } } catch (err: unknown) { return { status: 'error', message: (err as Error).message } } } // Validate query_type hint vs. the query text (lightweight — GitHub's // GraphQL server is authoritative). function validateQueryType( queryType: 'query' | 'mutation', query: string, ): string | null { const trimmed = query.trim().toLowerCase() if (queryType === 'mutation' && !trimmed.startsWith('mutation')) { return "query_type='mutation' but query does not start with 'mutation'" } if ( queryType === 'query' && trimmed.startsWith('mutation') ) { return "query_type='query' but query starts with 'mutation'" } return null } // ──────────────────────────────────────────────────────────────────── // The universal tool // ──────────────────────────────────────────────────────────────────── export const useGithubTool = tool({ name: 'use_github', description: 'Execute any GitHub GraphQL query or mutation. Supports the full v4 schema: ' + 'repositories, issues, PRs, users, orgs, projects, commits, branches, security. ' + 'Requires githubToken in Settings. Returns data + rate limit info.', inputSchema: z.object({ query_type: z .enum(['query', 'mutation']) .describe('Type of GraphQL operation'), query: z.string().describe('The GraphQL query or mutation string'), variables: z .record(z.string(), z.any()) .optional() .describe('Optional variables for the query'), label: z .string() .optional() .describe('Human-readable description of the operation (for logs)'), }), callback: async ({ query_type, query, variables, label }) => { const validationError = validateQueryType(query_type, query) if (validationError) { return JSON.stringify({ status: 'error', message: validationError }) } if (label) { // eslint-disable-next-line no-console console.log(`[use_github] ${query_type}: ${label}`) } const result = await graphql(query, variables) return JSON.stringify(result) }, }) // ──────────────────────────────────────────────────────────────────── // Thin convenience wrappers (backward compat with existing callers) // All three build GraphQL under the hood — one auth path, one transport. // ──────────────────────────────────────────────────────────────────── export const githubSearchRepoTool = tool({ name: 'github_search_repos', description: 'Search GitHub repositories (convenience wrapper on use_github).', inputSchema: z.object({ query: z.string(), limit: z.number().optional(), }), callback: async ({ query, limit }) => { const gql = ` query($q: String!, $first: Int!) { search(query: $q, type: REPOSITORY, first: $first) { repositoryCount nodes { ... on Repository { nameWithOwner description stargazerCount forkCount primaryLanguage { name } url updatedAt } } } }` const result = await graphql(gql, { q: query, first: limit || 5 }) if (result.status !== 'success') return JSON.stringify(result) type SearchNode = { nameWithOwner: string description: string | null stargazerCount: number forkCount: number primaryLanguage: { name: string } | null url: string updatedAt: string } const search = (result.data as { search?: { repositoryCount: number; nodes: SearchNode[] } }) ?.search const results = search?.nodes.map((r) => ({ name: r.nameWithOwner, description: r.description, stars: r.stargazerCount, forks: r.forkCount, language: r.primaryLanguage?.name, url: r.url, updated_at: r.updatedAt, })) || [] return JSON.stringify({ status: 'success', total: search?.repositoryCount || 0, results, rate_limit: result.rate_limit, }) }, }) export const githubGetRepoTool = tool({ name: 'github_get_repo', description: 'Get GitHub repo info (convenience wrapper on use_github). Includes open issue + PR counts.', inputSchema: z.object({ owner: z.string(), name: z.string() }), callback: async ({ owner, name }) => { const gql = ` query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { nameWithOwner description stargazerCount forkCount primaryLanguage { name } url updatedAt pushedAt isArchived isFork defaultBranchRef { name } issues(states: OPEN) { totalCount } pullRequests(states: OPEN) { totalCount } licenseInfo { spdxId } diskUsage } }` const result = await graphql(gql, { owner, name }) if (result.status !== 'success') return JSON.stringify(result) type Repo = { nameWithOwner: string description: string | null stargazerCount: number forkCount: number primaryLanguage: { name: string } | null url: string updatedAt: string pushedAt: string isArchived: boolean isFork: boolean defaultBranchRef: { name: string } | null issues: { totalCount: number } pullRequests: { totalCount: number } licenseInfo: { spdxId: string } | null diskUsage: number } const r = (result.data as { repository: Repo | null })?.repository if (!r) { return JSON.stringify({ status: 'error', message: `Repo not found: ${owner}/${name}`, }) } return JSON.stringify({ status: 'success', name: r.nameWithOwner, description: r.description, stars: r.stargazerCount, forks: r.forkCount, language: r.primaryLanguage?.name, url: r.url, updated_at: r.updatedAt, pushed_at: r.pushedAt, archived: r.isArchived, fork: r.isFork, default_branch: r.defaultBranchRef?.name, open_issues: r.issues.totalCount, open_prs: r.pullRequests.totalCount, license: r.licenseInfo?.spdxId, disk_usage_kb: r.diskUsage, rate_limit: result.rate_limit, }) }, }) export const githubGetFileTool = tool({ name: 'github_get_file', description: 'Fetch a file from a GitHub repo, decoded. Uses GraphQL object/blob expression.', inputSchema: z.object({ owner: z.string(), name: z.string(), path: z.string(), branch: z.string().optional(), }), callback: async ({ owner, name, path, branch }) => { // expression: ":" — ref defaults to HEAD when omitted const expression = `${branch || 'HEAD'}:${path}` const gql = ` query($owner: String!, $name: String!, $expr: String!) { repository(owner: $owner, name: $name) { object(expression: $expr) { ... on Blob { text byteSize isBinary oid } } } }` const result = await graphql(gql, { owner, name, expr: expression }) if (result.status !== 'success') return JSON.stringify(result) type Blob = { text: string | null byteSize: number isBinary: boolean oid: string } const obj = ( result.data as { repository: { object: Blob | null } | null } )?.repository?.object if (!obj) { return JSON.stringify({ status: 'error', message: `File not found: ${owner}/${name}:${expression}`, }) } if (obj.isBinary) { return JSON.stringify({ status: 'error', message: `Binary file (${obj.byteSize} bytes) — GraphQL can't return bytes. Use raw.githubusercontent.com instead.`, oid: obj.oid, }) } return JSON.stringify({ status: 'success', path, size: obj.byteSize, oid: obj.oid, content: (obj.text || '').slice(0, 20000), rate_limit: result.rate_limit, }) }, }) export const GITHUB_TOOLS = [ useGithubTool, githubSearchRepoTool, githubGetRepoTool, githubGetFileTool, ]