From 4497e6a8cd4327d178fe1ac00fef14463b0762c9 Mon Sep 17 00:00:00 2001 From: therealemjy Date: Sat, 25 Jul 2026 19:41:24 +0200 Subject: [PATCH] ci: give AI read-only access to GitHub, Jira and Notion --- .../setup/GitHubIntegration/index.ts | 283 +++++++ .pi/extensions/setup/JiraIntegration/index.ts | 693 ++++++++++++++++++ .../setup/NotionIntegration/index.ts | 195 +++++ .pi/extensions/setup/SetupExtension/index.ts | 153 ++++ .pi/extensions/setup/SetupStorage/index.ts | 117 +++ .pi/extensions/setup/constants/index.ts | 21 + .pi/extensions/setup/index.ts | 6 + .pi/extensions/setup/types.ts | 141 ++++ .../setup/utils/errorMessage/index.ts | 7 + .../setup/utils/expiresAtMs/index.ts | 7 + .../setup/utils/formatSize/index.ts | 11 + .pi/extensions/setup/utils/index.ts | 15 + .pi/extensions/setup/utils/limit/index.ts | 7 + .../setup/utils/openBrowser/index.ts | 18 + .../setup/utils/parseScopes/index.ts | 5 + .../setup/utils/pkceChallenge/index.ts | 4 + .../setup/utils/randomUrlSafeString/index.ts | 4 + .../setup/utils/readJsonResponse/index.ts | 14 + .../setup/utils/runCommand/index.ts | 28 + .../utils/runInteractiveCommand/index.ts | 19 + .../utils/runInteractiveShellCommand/index.ts | 4 + .../setup/utils/textResult/index.ts | 4 + .pi/extensions/setup/utils/truncate/index.ts | 26 + .../setup/utils/unconfiguredResult/index.ts | 4 + AGENTS.md | 13 + README.md | 23 - 26 files changed, 1799 insertions(+), 23 deletions(-) create mode 100644 .pi/extensions/setup/GitHubIntegration/index.ts create mode 100644 .pi/extensions/setup/JiraIntegration/index.ts create mode 100644 .pi/extensions/setup/NotionIntegration/index.ts create mode 100644 .pi/extensions/setup/SetupExtension/index.ts create mode 100644 .pi/extensions/setup/SetupStorage/index.ts create mode 100644 .pi/extensions/setup/constants/index.ts create mode 100644 .pi/extensions/setup/index.ts create mode 100644 .pi/extensions/setup/types.ts create mode 100644 .pi/extensions/setup/utils/errorMessage/index.ts create mode 100644 .pi/extensions/setup/utils/expiresAtMs/index.ts create mode 100644 .pi/extensions/setup/utils/formatSize/index.ts create mode 100644 .pi/extensions/setup/utils/index.ts create mode 100644 .pi/extensions/setup/utils/limit/index.ts create mode 100644 .pi/extensions/setup/utils/openBrowser/index.ts create mode 100644 .pi/extensions/setup/utils/parseScopes/index.ts create mode 100644 .pi/extensions/setup/utils/pkceChallenge/index.ts create mode 100644 .pi/extensions/setup/utils/randomUrlSafeString/index.ts create mode 100644 .pi/extensions/setup/utils/readJsonResponse/index.ts create mode 100644 .pi/extensions/setup/utils/runCommand/index.ts create mode 100644 .pi/extensions/setup/utils/runInteractiveCommand/index.ts create mode 100644 .pi/extensions/setup/utils/runInteractiveShellCommand/index.ts create mode 100644 .pi/extensions/setup/utils/textResult/index.ts create mode 100644 .pi/extensions/setup/utils/truncate/index.ts create mode 100644 .pi/extensions/setup/utils/unconfiguredResult/index.ts diff --git a/.pi/extensions/setup/GitHubIntegration/index.ts b/.pi/extensions/setup/GitHubIntegration/index.ts new file mode 100644 index 0000000000..fe1c61be9f --- /dev/null +++ b/.pi/extensions/setup/GitHubIntegration/index.ts @@ -0,0 +1,283 @@ +import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'; +import { Type } from 'typebox'; +import type { SetupStorage } from '../SetupStorage'; +import { GITHUB_CLI_INSTALL_URL } from '../constants'; +import type { + GitHubReadFileParams, + GitHubSearchIssuesParams, + GitHubSearchRepositoriesParams, + ProviderMetadata, + SetupState, + StoredCredentials, +} from '../types'; +import { + limit, + openBrowser, + parseScopes, + readJsonResponse, + runCommand, + runInteractiveCommand, + textResult, + truncate, + unconfiguredResult, +} from '../utils'; + +export class GitHubIntegration { + constructor(private storage: SetupStorage) {} + + registerTools(pi: ExtensionAPI) { + pi.registerTool({ + name: 'github_search_repositories', + label: 'GitHub Search Repositories', + description: 'Search GitHub repositories using the connected read-only GitHub token.', + promptSnippet: + 'Search GitHub repositories when a connected read-only GitHub token is available', + promptGuidelines: [ + 'Use github_search_repositories only for read-only GitHub lookups and never for mutations.', + ], + parameters: Type.Object({ + query: Type.String({ description: 'GitHub repository search query' }), + limit: Type.Optional(Type.Number({ description: 'Maximum number of results, up to 20' })), + }), + execute: async (_toolCallId, params) => this.searchRepositories(params), + }); + + pi.registerTool({ + name: 'github_search_issues', + label: 'GitHub Search Issues', + description: + 'Search GitHub issues and pull requests using the connected read-only GitHub token.', + promptSnippet: 'Search GitHub issues and pull requests with read-only access', + promptGuidelines: ['Use github_search_issues only for read-only GitHub lookups.'], + parameters: Type.Object({ + query: Type.String({ description: 'GitHub issue search query' }), + repo: Type.Optional( + Type.String({ + description: 'Optional owner/repo to scope the search', + }), + ), + limit: Type.Optional(Type.Number({ description: 'Maximum number of results, up to 20' })), + }), + execute: async (_toolCallId, params) => this.searchIssues(params), + }); + + pi.registerTool({ + name: 'github_read_file', + label: 'GitHub Read File', + description: + 'Read a file from a GitHub repository using the connected read-only GitHub token.', + promptSnippet: 'Read files from GitHub repositories with read-only access', + promptGuidelines: ['Use github_read_file only to read repository contents.'], + parameters: Type.Object({ + owner: Type.String({ description: 'Repository owner' }), + repo: Type.String({ description: 'Repository name' }), + path: Type.String({ description: 'File path inside the repository' }), + ref: Type.Optional(Type.String({ description: 'Optional branch, tag, or commit SHA' })), + }), + execute: async (_toolCallId, params) => this.readFile(params), + }); + } + + async setup( + ctx: ExtensionContext, + credentials: StoredCredentials, + ): Promise { + if (!(await this.ensureCliInstalled(ctx))) { + return undefined; + } + + await this.sanitizeGitCredentialHelpers(); + + if (!(await this.isCliAuthenticated())) { + const shouldLogin = await ctx.ui.confirm( + 'GitHub login', + 'GitHub CLI is not authenticated. Run gh auth login --git-protocol https now?', + ); + if (!shouldLogin) { + return undefined; + } + + ctx.ui.notify('Starting GitHub CLI login in the terminal...', 'info'); + await runInteractiveCommand('gh', ['auth', 'login', '--git-protocol', 'https']); + } + + if (!(await this.isCliAuthenticated())) { + throw new Error('GitHub CLI authentication did not complete.'); + } + + const token = await this.getCliToken(); + if (!token) { + throw new Error('GitHub CLI did not return an auth token.'); + } + + const response = await fetch('https://api.github.com/user', { + headers: this.headers(token), + }); + + if (!response.ok) { + ctx.ui.notify(`GitHub validation failed: ${response.status} ${response.statusText}`, 'error'); + return undefined; + } + + const profile = (await response.json()) as { + login?: string; + name?: string; + }; + credentials.github = undefined; + + return { + connectedAtMs: Date.now(), + accountName: profile.login || profile.name || 'GitHub account', + scopes: parseScopes(response.headers.get('x-oauth-scopes') || ''), + url: 'https://github.com', + }; + } + + async statusLine(state: SetupState) { + const metadata = state.providers.github; + const token = await this.getApiToken(); + if (!token) { + return 'github: not connected'; + } + + const account = metadata?.accountName ? ` (${metadata.accountName})` : ' (via gh)'; + return `github: connected${account}`; + } + + private async sanitizeGitCredentialHelpers() { + const staleHelperPattern = /\/opt\/homebrew|\/usr\/local\/bin\/gh|Program Files/; + const hosts = ['https://github.com', 'https://gist.github.com']; + + for (const host of hosts) { + const key = `credential.${host}.helper`; + const helpers = await runCommand('git', ['config', '--global', '--get-all', key]); + if (!staleHelperPattern.test(helpers.stdout)) { + continue; + } + + await runCommand('git', ['config', '--global', '--unset-all', key]); + } + } + + private async ensureCliInstalled(ctx: ExtensionContext) { + const result = await runCommand('gh', ['--version']); + if (result.code === 0) { + return true; + } + + const shouldOpenInstructions = await ctx.ui.confirm( + 'Install GitHub CLI', + 'GitHub CLI is not installed. Open GitHub CLI install instructions now?', + ); + if (!shouldOpenInstructions) { + return false; + } + + openBrowser(GITHUB_CLI_INSTALL_URL); + ctx.ui.notify('Install GitHub CLI, then rerun /setup.', 'info'); + return false; + } + + private async isCliAuthenticated() { + const result = await runCommand('gh', ['auth', 'status']); + return result.code === 0; + } + + private async getCliToken() { + const result = await runCommand('gh', ['auth', 'token']); + if (result.code !== 0) { + return undefined; + } + + return result.stdout.trim() || undefined; + } + + private async getApiToken() { + const cliToken = await this.getCliToken(); + if (cliToken) { + return cliToken; + } + + const credentials = await this.storage.readCredentials(); + return credentials.github?.token; + } + + private async searchRepositories(params: GitHubSearchRepositoriesParams) { + const token = await this.getApiToken(); + if (!token) { + return unconfiguredResult('GitHub'); + } + + const url = new URL('https://api.github.com/search/repositories'); + url.searchParams.set('q', params.query); + url.searchParams.set('per_page', String(limit(params.limit))); + + const response = await fetch(url, { headers: this.headers(token) }); + const payload = await readJsonResponse(response, 'GitHub repository search'); + + return textResult(truncate(JSON.stringify(payload, undefined, 2))); + } + + private async searchIssues(params: GitHubSearchIssuesParams) { + const token = await this.getApiToken(); + if (!token) { + return unconfiguredResult('GitHub'); + } + + const url = new URL('https://api.github.com/search/issues'); + const repoPrefix = params.repo ? `repo:${params.repo} ` : ''; + url.searchParams.set('q', `${repoPrefix}${params.query}`); + url.searchParams.set('per_page', String(limit(params.limit))); + + const response = await fetch(url, { headers: this.headers(token) }); + const payload = await readJsonResponse(response, 'GitHub issue search'); + + return textResult(truncate(JSON.stringify(payload, undefined, 2))); + } + + private async readFile(params: GitHubReadFileParams) { + const token = await this.getApiToken(); + if (!token) { + return unconfiguredResult('GitHub'); + } + + const encodedPath = params.path + .split('/') + .map(segment => encodeURIComponent(segment)) + .join('/'); + const url = new URL( + `https://api.github.com/repos/${encodeURIComponent(params.owner)}/${encodeURIComponent( + params.repo, + )}/contents/${encodedPath}`, + ); + if (params.ref) { + url.searchParams.set('ref', params.ref); + } + + const response = await fetch(url, { headers: this.headers(token) }); + const payload = (await readJsonResponse(response, 'GitHub file read')) as { + content?: string; + encoding?: string; + path?: string; + html_url?: string; + }; + + if (payload.encoding !== 'base64' || !payload.content) { + return textResult(truncate(JSON.stringify(payload, undefined, 2))); + } + + const text = Buffer.from(payload.content.replaceAll('\n', ''), 'base64').toString('utf8'); + const header = `# ${payload.path || params.path}\n${payload.html_url || ''}\n\n`; + + return textResult(truncate(`${header}${text}`)); + } + + private headers(token: string) { + return { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'venus-pi-setup', + }; + } +} diff --git a/.pi/extensions/setup/JiraIntegration/index.ts b/.pi/extensions/setup/JiraIntegration/index.ts new file mode 100644 index 0000000000..e2837d0d8d --- /dev/null +++ b/.pi/extensions/setup/JiraIntegration/index.ts @@ -0,0 +1,693 @@ +import { type Server, createServer } from 'node:http'; +import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'; +import { Type } from 'typebox'; +import type { SetupStorage } from '../SetupStorage'; +import { + JIRA_MCP_AUTHORIZATION_METADATA_URL, + JIRA_MCP_RESOURCE_URL, + JIRA_MCP_SCOPE, + JIRA_MCP_SERVER_URL, + JIRA_OAUTH_CALLBACK_PATH, + JIRA_OAUTH_CALLBACK_PORT, + MCP_PROTOCOL_VERSION, + SETUP_VERSION, + VENUS_PROTOCOL_JIRA_PROJECT_KEY, +} from '../constants'; +import type { + JiraMcpMessage, + JiraMcpRequestResult, + JiraMcpResource, + JiraMcpToolResult, + JiraOAuthClientRegistrationResponse, + JiraOAuthMetadata, + JiraOAuthTokenResponse, + JiraReadIssueParams, + JiraSearchIssuesParams, + OAuthCallbackServer, + ProviderMetadata, + SetupState, + StoredCredentials, +} from '../types'; +import { + expiresAtMs, + limit, + openBrowser, + parseScopes, + pkceChallenge, + randomUrlSafeString, + readJsonResponse, + textResult, + truncate, + unconfiguredResult, +} from '../utils'; + +export class JiraIntegration { + constructor(private storage: SetupStorage) {} + + registerTools(pi: ExtensionAPI) { + pi.registerTool({ + name: 'jira_search_issues', + label: 'Jira Search Issues', + description: + 'Search Venus Protocol Jira issues with JQL using the connected Atlassian Rovo MCP session.', + promptSnippet: 'Search Venus Protocol Jira issues with read-only Atlassian Rovo MCP access', + promptGuidelines: [ + 'Use jira_search_issues only for read-only Jira MCP lookups. Searches are scoped to the Venus Protocol project.', + ], + parameters: Type.Object({ + jql: Type.String({ + description: 'Jira JQL query. Project scoping is applied automatically.', + }), + limit: Type.Optional(Type.Number({ description: 'Maximum number of results, up to 20' })), + }), + execute: async (_toolCallId, params) => this.searchIssues(params), + }); + + pi.registerTool({ + name: 'jira_read_issue', + label: 'Jira Read Issue', + description: 'Read a Jira issue using the connected Atlassian Rovo MCP session.', + promptSnippet: 'Read Jira issues with read-only Atlassian Rovo MCP access', + promptGuidelines: ['Use jira_read_issue only to read Jira issue content through MCP.'], + parameters: Type.Object({ + issueKey: Type.String({ + description: 'Jira issue key, for example PROJ-123', + }), + }), + execute: async (_toolCallId, params) => this.readIssue(params), + }); + } + + async setup( + ctx: ExtensionContext, + credentials: StoredCredentials, + ): Promise { + const token = await this.login(ctx); + if (!token) { + return undefined; + } + + const connection = { + token: token.accessToken, + clientId: token.clientId, + refreshToken: token.refreshToken, + expiresAtMs: token.expiresAtMs, + cloudId: '', + siteUrl: '', + }; + const resourcesResult = await this.callTool(connection, 'getAccessibleAtlassianResources', {}); + const resources = this.parseResources(resourcesResult); + if (resources.length === 0) { + ctx.ui.notify('Jira validation failed: no accessible Atlassian sites found.', 'error'); + return undefined; + } + + const resource = await this.selectResource(ctx, resources); + if (!resource) { + return undefined; + } + + credentials.jira = { + token: token.accessToken, + clientId: token.clientId, + refreshToken: token.refreshToken, + expiresAtMs: token.expiresAtMs, + cloudId: resource.id, + siteUrl: resource.url || '', + projectKeys: [VENUS_PROTOCOL_JIRA_PROJECT_KEY], + }; + + return { + connectedAtMs: Date.now(), + accountName: resource.name || resource.url || 'Jira site', + scopes: token.scopes, + url: resource.url, + }; + } + + async statusLine(state: SetupState) { + const credentials = await this.storage.readCredentials(); + const jira = credentials.jira; + if (!jira?.clientId) { + return 'jira: not connected'; + } + + const metadata = state.providers.jira; + const account = metadata?.accountName || jira.siteUrl || 'Atlassian Rovo MCP'; + return `jira: connected (${account}) project: ${VENUS_PROTOCOL_JIRA_PROJECT_KEY}`; + } + + private async login(ctx: ExtensionContext): Promise< + | { + accessToken: string; + clientId: string; + refreshToken?: string; + expiresAtMs?: number; + scopes?: string[]; + } + | undefined + > { + const state = randomUrlSafeString(); + const codeVerifier = randomUrlSafeString(64); + const callbackServer = await this.startOAuthCallbackServer({ + provider: 'Jira', + callbackPath: JIRA_OAUTH_CALLBACK_PATH, + port: JIRA_OAUTH_CALLBACK_PORT, + state, + }); + + try { + const metadata = await this.getOAuthMetadata(); + const client = await this.registerOAuthClient(callbackServer.redirectUri, metadata); + const authUrl = new URL(metadata.authorization_endpoint); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('client_id', client.clientId); + authUrl.searchParams.set('scope', JIRA_MCP_SCOPE); + authUrl.searchParams.set('redirect_uri', callbackServer.redirectUri); + authUrl.searchParams.set('state', state); + authUrl.searchParams.set('prompt', 'consent'); + authUrl.searchParams.set('resource', JIRA_MCP_RESOURCE_URL); + authUrl.searchParams.set('code_challenge', pkceChallenge(codeVerifier)); + authUrl.searchParams.set('code_challenge_method', 'S256'); + + openBrowser(authUrl.toString()); + ctx.ui.notify('Waiting for Jira authorization in your browser...', 'info'); + + const code = await callbackServer.waitForCode; + const token = await this.exchangeCode( + code, + codeVerifier, + callbackServer.redirectUri, + client.clientId, + metadata, + ); + if (!token.access_token) { + throw new Error( + token.error_description || token.error || 'Jira did not return an access token.', + ); + } + + return { + accessToken: token.access_token, + clientId: client.clientId, + refreshToken: token.refresh_token, + expiresAtMs: expiresAtMs(token.expires_in), + scopes: parseScopes(token.scope || ''), + }; + } finally { + await callbackServer.close(); + } + } + + private async getOAuthMetadata() { + const response = await fetch(JIRA_MCP_AUTHORIZATION_METADATA_URL, { + headers: { Accept: 'application/json' }, + }); + + return (await readJsonResponse(response, 'Jira MCP OAuth metadata')) as JiraOAuthMetadata; + } + + private async registerOAuthClient(redirectUri: string, metadata: JiraOAuthMetadata) { + if (!metadata.registration_endpoint) { + throw new Error('Jira MCP OAuth server does not support dynamic client registration.'); + } + + const response = await fetch(metadata.registration_endpoint, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + redirect_uris: [redirectUri], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + client_name: 'Venus Pi setup', + client_uri: 'https://github.com/VenusProtocol/venus-protocol', + software_version: String(SETUP_VERSION), + scope: JIRA_MCP_SCOPE, + }), + }); + + const payload = (await response.json()) as JiraOAuthClientRegistrationResponse; + if (!response.ok || payload.error || !payload.client_id) { + throw new Error( + payload.error_description || + payload.error || + `Jira MCP client registration failed: ${response.status}`, + ); + } + + return { clientId: payload.client_id }; + } + + private async exchangeCode( + code: string, + codeVerifier: string, + redirectUri: string, + clientId: string, + metadata: JiraOAuthMetadata, + ) { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + resource: JIRA_MCP_RESOURCE_URL, + }); + + return this.requestOAuthToken(metadata.token_endpoint, body, 'Jira MCP OAuth'); + } + + private async refreshToken(refreshToken: string, clientId: string) { + const metadata = await this.getOAuthMetadata(); + const body = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: refreshToken, + resource: JIRA_MCP_RESOURCE_URL, + }); + + return this.requestOAuthToken(metadata.token_endpoint, body, 'Jira MCP token refresh'); + } + + private async requestOAuthToken(tokenEndpoint: string, body: URLSearchParams, label: string) { + const response = await fetch(tokenEndpoint, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }); + + const payload = (await response.json()) as JiraOAuthTokenResponse; + if (!response.ok || payload.error) { + throw new Error( + payload.error_description || payload.error || `${label} failed: ${response.status}`, + ); + } + + return payload; + } + + private async startOAuthCallbackServer({ + provider, + callbackPath, + port, + state, + }: { + provider: string; + callbackPath: string; + port: number; + state: string; + }): Promise { + const redirectUri = `http://localhost:${port}${callbackPath}`; + const server = createServer(); + + const waitForCode = new Promise((resolveCode, rejectCode) => { + server.on('request', (request, response) => { + const requestUrl = new URL(request.url || '/', redirectUri); + const code = requestUrl.searchParams.get('code') || undefined; + const returnedState = requestUrl.searchParams.get('state') || undefined; + const error = requestUrl.searchParams.get('error') || undefined; + const errorDescription = requestUrl.searchParams.get('error_description') || undefined; + + if (requestUrl.pathname !== callbackPath) { + response.writeHead(404, { 'Content-Type': 'text/plain' }); + response.end('Not found'); + return; + } + + if (error) { + response.writeHead(400, { 'Content-Type': 'text/html' }); + response.end(this.oauthHtml(`${provider} authorization failed. You can close this tab.`)); + rejectCode(new Error(errorDescription || error)); + return; + } + + if (returnedState !== state) { + response.writeHead(400, { 'Content-Type': 'text/html' }); + response.end(this.oauthHtml(`${provider} authorization failed. You can close this tab.`)); + rejectCode(new Error(`${provider} OAuth state mismatch.`)); + return; + } + + if (!code) { + response.writeHead(400, { 'Content-Type': 'text/html' }); + response.end( + this.oauthHtml(`${provider} did not return a code. You can close this tab.`), + ); + rejectCode(new Error(`${provider} did not return an authorization code.`)); + return; + } + + response.writeHead(200, { 'Content-Type': 'text/html' }); + response.end(this.oauthHtml(`${provider} authorization complete. You can close this tab.`)); + resolveCode(code); + }); + + server.on('error', error => { + rejectCode(error); + }); + }); + + await this.listen(server, port); + + return { + redirectUri, + waitForCode, + close: async () => this.closeServer(server), + }; + } + + private listen(server: Server, port: number) { + return new Promise((resolveListen, rejectListen) => { + server.once('error', rejectListen); + server.listen(port, 'localhost', () => { + server.off('error', rejectListen); + resolveListen(); + }); + }); + } + + private closeServer(server: Server) { + return new Promise(resolveClose => { + if (!server.listening) { + resolveClose(); + return; + } + + server.close(() => { + resolveClose(); + }); + }); + } + + private oauthHtml(message: string) { + return `Venus Pi setup${message}`; + } + + private async selectResource(ctx: ExtensionContext, resources: JiraMcpResource[]) { + if (resources.length === 1) { + return resources[0]; + } + + const labels = resources.map( + resource => `${resource.name || resource.url || resource.id} (${resource.id})`, + ); + const selected = await ctx.ui.select('Select Jira site', labels); + if (!selected) { + return undefined; + } + + const selectedIndex = labels.indexOf(selected); + return resources[selectedIndex]; + } + + private async searchIssues(params: JiraSearchIssuesParams) { + const jira = await this.getConfiguredJira(); + if (!jira) { + return unconfiguredResult('Jira'); + } + + const payload = await this.callTool(jira, 'searchJiraIssuesUsingJql', { + cloudId: jira.cloudId, + jql: this.scopedJql(params.jql), + maxResults: limit(params.limit), + fields: [ + 'key', + 'summary', + 'status', + 'assignee', + 'reporter', + 'issuetype', + 'priority', + 'updated', + ], + responseContentFormat: 'markdown', + }); + + return textResult(truncate(this.formatToolResult(payload))); + } + + private async readIssue(params: JiraReadIssueParams) { + const jira = await this.getConfiguredJira(); + if (!jira) { + return unconfiguredResult('Jira'); + } + + const payload = await this.callTool(jira, 'getJiraIssue', { + cloudId: jira.cloudId, + issueIdOrKey: params.issueKey, + fields: [ + 'key', + 'summary', + 'status', + 'assignee', + 'reporter', + 'issuetype', + 'priority', + 'description', + 'comment', + 'updated', + 'created', + ], + responseContentFormat: 'markdown', + }); + + return textResult(truncate(this.formatToolResult(payload))); + } + + private async callTool( + jira: NonNullable, + name: string, + args: Record, + ) { + const initialize = await this.mcpRequest(jira.token, 'initialize', { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'venus-pi-setup', version: String(SETUP_VERSION) }, + }); + const sessionId = initialize.sessionId; + + await this.mcpNotification(jira.token, 'notifications/initialized', {}, sessionId); + + const response = await this.mcpRequest( + jira.token, + 'tools/call', + { name, arguments: args }, + sessionId, + ); + const result = response.message?.result as JiraMcpToolResult | undefined; + if (!result) { + throw new Error(`Jira MCP tool ${name} returned no result.`); + } + + if (result.isError) { + throw new Error(this.formatToolResult(result)); + } + + return result; + } + + private async mcpNotification( + token: string, + method: string, + params: Record, + sessionId: string | undefined, + ) { + await this.mcpHttp(token, { jsonrpc: '2.0', method, params }, sessionId); + } + + private async mcpRequest( + token: string, + method: string, + params: Record, + sessionId?: string, + ) { + const id = Date.now(); + const result = await this.mcpHttp(token, { jsonrpc: '2.0', id, method, params }, sessionId); + if (result.message?.error) { + throw new Error(result.message.error.message || `Jira MCP request ${method} failed.`); + } + + return result; + } + + private async mcpHttp( + token: string, + body: Record, + sessionId?: string, + ): Promise { + const headers: Record = { + Accept: 'application/json, text/event-stream', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'mcp-protocol-version': MCP_PROTOCOL_VERSION, + }; + if (sessionId) { + headers['mcp-session-id'] = sessionId; + } + + const response = await fetch(JIRA_MCP_SERVER_URL, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + const nextSessionId = response.headers.get('mcp-session-id') || sessionId; + const text = await response.text(); + if (!response.ok) { + throw new Error(`Jira MCP request failed: ${response.status} ${truncate(text)}`); + } + + if (response.status === 202 || !text.trim()) { + return { sessionId: nextSessionId }; + } + + return { + sessionId: nextSessionId, + message: this.parseMcpResponseMessage(text, response.headers.get('content-type') || ''), + }; + } + + private parseMcpResponseMessage(text: string, contentType: string): JiraMcpMessage { + if (contentType.includes('text/event-stream')) { + const dataLine = text.split('\n').find(line => line.startsWith('data:')); + if (!dataLine) { + throw new Error(`Jira MCP returned an empty event stream: ${truncate(text)}`); + } + + return JSON.parse(dataLine.slice('data:'.length).trim()) as JiraMcpMessage; + } + + const payload = JSON.parse(text) as JiraMcpMessage | JiraMcpMessage[]; + return Array.isArray(payload) ? payload[0] : payload; + } + + private formatToolResult(result: JiraMcpToolResult) { + const text = result.content + ?.map(item => (typeof item.text === 'string' ? item.text : JSON.stringify(item))) + .filter(Boolean) + .join('\n'); + + return text || JSON.stringify(result, undefined, 2); + } + + private parseResources(result: JiraMcpToolResult): JiraMcpResource[] { + const payload = this.parseToolJson(result); + const objects = this.collectObjects(payload); + + return objects + .map(item => ({ + id: this.stringField(item, 'id') || this.stringField(item, 'cloudId') || '', + name: this.stringField(item, 'name'), + url: this.stringField(item, 'url'), + })) + .filter(resource => resource.id); + } + + private parseToolJson(result: JiraMcpToolResult) { + const text = this.formatToolResult(result).trim(); + const withoutFence = text + .replace(/^```(?:json)?\s*/i, '') + .replace(/```$/i, '') + .trim(); + + try { + return JSON.parse(withoutFence) as unknown; + } catch { + const match = withoutFence.match(/(\[[\s\S]*\]|\{[\s\S]*\})/); + if (!match) { + return undefined; + } + + try { + return JSON.parse(match[1]) as unknown; + } catch { + return undefined; + } + } + } + + private collectObjects(value: unknown): Record[] { + if (Array.isArray(value)) { + return value.flatMap(item => this.collectObjects(item)); + } + + if (!value || typeof value !== 'object') { + return []; + } + + const record = value as Record; + const children = Object.values(record).flatMap(item => this.collectObjects(item)); + return [record, ...children]; + } + + private stringField(record: Record, field: string) { + const value = record[field]; + return typeof value === 'string' ? value : undefined; + } + + private scopedJql(jql: string) { + const scope = `project = "${VENUS_PROTOCOL_JIRA_PROJECT_KEY}"`; + const trimmedJql = jql.trim(); + if (!trimmedJql) { + return scope; + } + + const orderByIndex = trimmedJql.search(/\border\s+by\b/i); + if (orderByIndex < 0) { + return `${scope} AND (${trimmedJql})`; + } + + const filter = trimmedJql.slice(0, orderByIndex).trim(); + const orderBy = trimmedJql.slice(orderByIndex).trim(); + return `${scope} AND (${filter || 'key is not EMPTY'}) ${orderBy}`; + } + + private async getConfiguredJira() { + const credentials = await this.storage.readCredentials(); + const jira = credentials.jira; + if (!jira) { + return undefined; + } + + if (!jira.clientId) { + return undefined; + } + + if (!this.shouldRefresh(jira)) { + return jira; + } + + if (!jira.refreshToken) { + return jira; + } + + const refreshedToken = await this.refreshToken(jira.refreshToken, jira.clientId); + if (!refreshedToken.access_token) { + return jira; + } + + credentials.jira = { + ...jira, + token: refreshedToken.access_token, + refreshToken: refreshedToken.refresh_token || jira.refreshToken, + expiresAtMs: expiresAtMs(refreshedToken.expires_in), + }; + await this.storage.writeCredentials(credentials); + + return credentials.jira; + } + + private shouldRefresh(jira: NonNullable) { + if (!jira.expiresAtMs) { + return false; + } + + return jira.expiresAtMs <= Date.now() + 60_000; + } +} diff --git a/.pi/extensions/setup/NotionIntegration/index.ts b/.pi/extensions/setup/NotionIntegration/index.ts new file mode 100644 index 0000000000..22613a0baf --- /dev/null +++ b/.pi/extensions/setup/NotionIntegration/index.ts @@ -0,0 +1,195 @@ +import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'; +import { Type } from 'typebox'; +import { NOTION_CLI_INSTALL_COMMAND } from '../constants'; +import type { + NotionCliApiResult, + NotionReadPageParams, + NotionSearchPagesParams, + NotionUserMeResponse, + ProviderMetadata, + SetupState, +} from '../types'; +import { + limit, + runCommand, + runInteractiveCommand, + runInteractiveShellCommand, + textResult, + truncate, +} from '../utils'; + +export class NotionIntegration { + registerTools(pi: ExtensionAPI) { + pi.registerTool({ + name: 'notion_search_pages', + label: 'Notion Search Pages', + description: 'Search Notion pages using the connected Notion CLI session.', + promptSnippet: 'Search Notion pages with read-only access', + promptGuidelines: ['Use notion_search_pages only for read-only Notion lookups.'], + parameters: Type.Object({ + query: Type.String({ description: 'Search query' }), + limit: Type.Optional(Type.Number({ description: 'Maximum number of results, up to 20' })), + }), + execute: async (_toolCallId, params) => this.searchPages(params), + }); + + pi.registerTool({ + name: 'notion_read_page', + label: 'Notion Read Page', + description: + 'Read Notion page metadata and first-level blocks using the connected Notion CLI session.', + promptSnippet: 'Read Notion pages with read-only access', + promptGuidelines: ['Use notion_read_page only to read Notion content.'], + parameters: Type.Object({ + pageId: Type.String({ description: 'Notion page ID' }), + }), + execute: async (_toolCallId, params) => this.readPage(params), + }); + } + + async setup(ctx: ExtensionContext): Promise { + if (!(await this.ensureCliInstalled(ctx))) { + return undefined; + } + + let profile = await this.getCliProfile(); + if (!profile) { + const shouldLogin = await ctx.ui.confirm( + 'Notion login', + 'Notion CLI is not authenticated. Run ntn login now?', + ); + if (!shouldLogin) { + return undefined; + } + + ctx.ui.notify('Starting Notion CLI login in the terminal...', 'info'); + await runInteractiveCommand('ntn', ['login']); + profile = await this.getCliProfile(); + } + + if (!profile) { + throw new Error('Notion CLI authentication did not complete.'); + } + + return { + connectedAtMs: Date.now(), + accountName: this.accountName(profile), + url: 'https://notion.so', + }; + } + + async statusLine(state: SetupState) { + const metadata = state.providers.notion; + const profile = await this.getCliProfile(); + if (!profile && !metadata) { + return 'notion: not connected'; + } + + const account = metadata?.accountName || (profile ? this.accountName(profile) : 'via ntn'); + return `notion: connected (${account})`; + } + + private async ensureCliInstalled(ctx: ExtensionContext) { + const result = await runCommand('ntn', ['--version']); + if (result.code === 0) { + return true; + } + + const shouldInstall = await ctx.ui.confirm( + 'Install Notion CLI', + `Notion CLI is not installed. Run ${NOTION_CLI_INSTALL_COMMAND} now?`, + ); + if (!shouldInstall) { + return false; + } + + ctx.ui.notify('Installing Notion CLI in the terminal...', 'info'); + await runInteractiveShellCommand(NOTION_CLI_INSTALL_COMMAND); + + const installedResult = await runCommand('ntn', ['--version']); + if (installedResult.code === 0) { + return true; + } + + throw new Error('Notion CLI install completed, but ntn is still unavailable in PATH.'); + } + + private async getCliProfile() { + const response = await this.runCliApi('v1/users/me', [], 'Notion profile'); + if (response.ok === false) { + return undefined; + } + + return response.payload as NotionUserMeResponse; + } + + private accountName(profile: NotionUserMeResponse) { + return ( + profile.workspace_name || + profile.bot?.workspace_name || + profile.name || + profile.person?.email || + profile.workspace_id || + profile.bot?.workspace_id || + profile.id || + 'Notion workspace' + ); + } + + private async runCliApi( + path: string, + args: string[], + label: string, + ): Promise { + const result = await runCommand('ntn', ['api', path, ...args]); + if (result.code !== 0) { + return { ok: false, message: result.stderr.trim() || `${label} failed.` }; + } + + try { + return { ok: true, payload: JSON.parse(result.stdout) as unknown }; + } catch { + throw new Error(`${label} returned invalid JSON: ${truncate(result.stdout)}`); + } + } + + private async searchPages(params: NotionSearchPagesParams) { + const body = { + query: params.query, + page_size: limit(params.limit), + filter: { property: 'object', value: 'page' }, + }; + const cliResult = await this.runCliApi( + 'v1/search', + ['--data', JSON.stringify(body)], + 'Notion search', + ); + if (cliResult.ok === false) { + return textResult(`Notion CLI request failed: ${cliResult.message}`); + } + + return textResult(truncate(JSON.stringify(cliResult.payload, undefined, 2))); + } + + private async readPage(params: NotionReadPageParams) { + const pageResult = await this.runCliApi(`v1/pages/${params.pageId}`, [], 'Notion page read'); + if (pageResult.ok === false) { + return textResult(`Notion CLI request failed: ${pageResult.message}`); + } + + const blocksResult = await this.runCliApi( + `v1/blocks/${params.pageId}/children`, + ['page_size==50'], + 'Notion block read', + ); + if (blocksResult.ok === false) { + throw new Error(blocksResult.message); + } + + return textResult( + truncate( + JSON.stringify({ page: pageResult.payload, blocks: blocksResult.payload }, undefined, 2), + ), + ); + } +} diff --git a/.pi/extensions/setup/SetupExtension/index.ts b/.pi/extensions/setup/SetupExtension/index.ts new file mode 100644 index 0000000000..20699ad201 --- /dev/null +++ b/.pi/extensions/setup/SetupExtension/index.ts @@ -0,0 +1,153 @@ +import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'; +import { GitHubIntegration } from '../GitHubIntegration'; +import { JiraIntegration } from '../JiraIntegration'; +import { NotionIntegration } from '../NotionIntegration'; +import { SetupStorage } from '../SetupStorage'; +import type { ProviderMetadata, ProviderName, SetupState } from '../types'; +import { errorMessage } from '../utils'; + +export class SetupExtension { + private storage = new SetupStorage(); + private github = new GitHubIntegration(this.storage); + private notion = new NotionIntegration(); + private jira = new JiraIntegration(this.storage); + + constructor(private pi: ExtensionAPI) {} + + register() { + this.pi.on('session_start', async (_event, ctx) => { + await this.runStartupCheck(ctx); + }); + + this.pi.on('tool_call', async (event, ctx) => { + if (this.storage.isBlockedCredentialAccess(event.toolName, event.input, ctx.cwd)) { + return { + block: true, + reason: 'Access to local AI integration credentials is blocked.', + }; + } + }); + + this.pi.registerCommand('setup', { + description: 'Set up optional read-only context integrations', + handler: async (_args, ctx) => { + await this.runWizard(ctx); + }, + }); + + this.pi.registerCommand('status', { + description: 'Show optional AI integration setup status', + handler: async (_args, ctx) => { + ctx.ui.notify(await this.buildStatusMessage(), 'info'); + }, + }); + + this.github.registerTools(this.pi); + this.notion.registerTools(this.pi); + this.jira.registerTools(this.pi); + } + + private async runStartupCheck(ctx: ExtensionContext) { + if (!ctx.hasUI) { + return; + } + + const state = await this.storage.readState(); + if (state.promptDismissed || this.hasConnectedProvider(state)) { + return; + } + + const shouldSetup = await ctx.ui.confirm( + 'Optional setup', + 'Set up optional read-only GitHub, Notion, and Jira context integrations?', + ); + + if (!shouldSetup) { + await this.storage.writeState({ ...state, promptDismissed: true }); + return; + } + + await this.runWizard(ctx); + } + + private async runWizard(ctx: ExtensionContext) { + if (!ctx.hasUI) { + ctx.ui.notify('AI setup requires interactive mode.', 'warning'); + return; + } + + ctx.ui.notify( + 'Setup is optional. GitHub auth uses gh, Notion auth uses ntn, and Jira uses Atlassian Rovo MCP browser auth. Tokens are stored locally for read-only tools.', + 'info', + ); + + let state = await this.storage.readState(); + const credentials = await this.storage.readCredentials(); + + try { + if (await ctx.ui.confirm('GitHub', 'Connect GitHub with the GitHub CLI?')) { + const metadata = await this.github.setup(ctx, credentials); + if (metadata) { + state = this.withProvider(state, 'github', metadata); + } + } + } catch (error) { + ctx.ui.notify(`GitHub setup failed: ${errorMessage(error)}`, 'error'); + } + + try { + if (await ctx.ui.confirm('Notion', 'Connect Notion with the Notion CLI?')) { + const metadata = await this.notion.setup(ctx); + if (metadata) { + state = this.withProvider(state, 'notion', metadata); + } + } + } catch (error) { + ctx.ui.notify(`Notion setup failed: ${errorMessage(error)}`, 'error'); + } + + try { + if (await ctx.ui.confirm('Jira', 'Connect Jira with browser login?')) { + const metadata = await this.jira.setup(ctx, credentials); + if (metadata) { + state = this.withProvider(state, 'jira', metadata); + } + } + } catch (error) { + ctx.ui.notify(`Jira setup failed: ${errorMessage(error)}`, 'error'); + } + + await this.storage.writeCredentials(credentials); + await this.storage.writeState({ ...state, promptDismissed: true }); + ctx.ui.notify(await this.buildStatusMessage(), 'info'); + } + + private async buildStatusMessage() { + const state = await this.storage.readState(); + const lines = [ + await this.github.statusLine(state), + await this.notion.statusLine(state), + await this.jira.statusLine(state), + ]; + + return `AI integrations:\n${lines.join('\n')}`; + } + + private withProvider( + state: SetupState, + provider: ProviderName, + metadata: ProviderMetadata, + ): SetupState { + return { + ...state, + providers: { + ...state.providers, + [provider]: metadata, + }, + }; + } + + private hasConnectedProvider(state: SetupState) { + return Boolean(state.providers.github || state.providers.notion || state.providers.jira); + } +} diff --git a/.pi/extensions/setup/SetupStorage/index.ts b/.pi/extensions/setup/SetupStorage/index.ts new file mode 100644 index 0000000000..4fe84ca6d2 --- /dev/null +++ b/.pi/extensions/setup/SetupStorage/index.ts @@ -0,0 +1,117 @@ +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { + BLOCKED_PATH_HINT, + CREDENTIALS_FILE_NAME, + SETUP_DIR_NAME, + SETUP_VERSION, + STATE_FILE_NAME, + STORAGE_DIR_PATH_HINT, +} from '../constants'; +import type { SetupState, StoredCredentials } from '../types'; + +export class SetupStorage { + readonly statePath: string; + readonly credentialsPath: string; + readonly storageDir: string; + + constructor() { + this.storageDir = resolve( + process.env.PI_CODING_AGENT_DIR || resolve(homedir(), '.pi', 'agent'), + SETUP_DIR_NAME, + ); + this.statePath = resolve(this.storageDir, STATE_FILE_NAME); + this.credentialsPath = resolve(this.storageDir, CREDENTIALS_FILE_NAME); + } + + async readState(): Promise { + try { + const content = await readFile(this.statePath, 'utf8'); + const parsed = JSON.parse(content) as Partial; + return { + version: parsed.version || SETUP_VERSION, + promptDismissed: parsed.promptDismissed === true, + providers: parsed.providers || {}, + }; + } catch { + return { version: SETUP_VERSION, promptDismissed: false, providers: {} }; + } + } + + async writeState(state: SetupState) { + await this.ensureStorageDir(); + await writeFile(this.statePath, `${JSON.stringify(state, undefined, 2)}\n`, { mode: 0o600 }); + await chmod(this.statePath, 0o600); + } + + async readCredentials(): Promise { + try { + const content = await readFile(this.credentialsPath, 'utf8'); + const parsed = JSON.parse(content) as Partial; + return { + github: parsed.github, + jira: parsed.jira, + }; + } catch { + return {}; + } + } + + async writeCredentials(credentials: StoredCredentials) { + await this.ensureStorageDir(); + await writeFile(this.credentialsPath, `${JSON.stringify(credentials, undefined, 2)}\n`, { + mode: 0o600, + }); + await chmod(this.credentialsPath, 0o600); + } + + async ensureStorageDir() { + await mkdir(dirname(this.statePath), { recursive: true, mode: 0o700 }); + await chmod(dirname(this.statePath), 0o700); + } + + isBlockedCredentialAccess(toolName: string, input: unknown, cwd: string) { + if (toolName === 'bash') { + const command = (input as { command?: unknown }).command; + return typeof command === 'string' && this.containsBlockedCredentialPath(command); + } + + if (!['read', 'write', 'edit'].includes(toolName)) { + return false; + } + + const path = (input as { path?: unknown }).path; + if (typeof path !== 'string') { + return false; + } + + const resolvedPath = resolve(cwd, this.expandHomeDir(path)); + return this.isCredentialPath(resolvedPath); + } + + private containsBlockedCredentialPath(command: string) { + return ( + command.includes(BLOCKED_PATH_HINT) || + command.includes(STORAGE_DIR_PATH_HINT) || + command.includes(this.credentialsPath) || + command.includes(this.storageDir) + ); + } + + private expandHomeDir(path: string) { + if (path === '~') { + return homedir(); + } + + if (path.startsWith('~/')) { + return resolve(homedir(), path.slice(2)); + } + + return path; + } + + private isCredentialPath(path: string) { + return path === this.credentialsPath || path.startsWith(`${this.storageDir}/`); + } +} diff --git a/.pi/extensions/setup/constants/index.ts b/.pi/extensions/setup/constants/index.ts new file mode 100644 index 0000000000..06b2768270 --- /dev/null +++ b/.pi/extensions/setup/constants/index.ts @@ -0,0 +1,21 @@ +export const SETUP_VERSION = 1; +export const SETUP_DIR_NAME = 'venus'; +export const STATE_FILE_NAME = 'state.json'; +export const CREDENTIALS_FILE_NAME = 'credentials.json'; +export const BLOCKED_PATH_HINT = `${SETUP_DIR_NAME}/${CREDENTIALS_FILE_NAME}`; +export const STORAGE_DIR_PATH_HINT = `.pi/agent/${SETUP_DIR_NAME}`; +export const DEFAULT_MAX_BYTES = 50 * 1024; +export const DEFAULT_MAX_LINES = 2000; + +export const GITHUB_CLI_INSTALL_URL = 'https://cli.github.com/'; +export const NOTION_CLI_INSTALL_COMMAND = 'curl -fsSL https://ntn.dev | bash'; + +export const JIRA_MCP_SERVER_URL = 'https://mcp.atlassian.com/v1/mcp/authv2'; +export const JIRA_MCP_RESOURCE_URL = JIRA_MCP_SERVER_URL; +export const JIRA_MCP_AUTHORIZATION_METADATA_URL = + 'https://auth.atlassian.com/VCeDsk8ZHncYF1g234fKtc4lNipbBhu3/.well-known/oauth-authorization-server'; +export const JIRA_MCP_SCOPE = 'read:me read:account read:jira-work offline_access'; +export const JIRA_OAUTH_CALLBACK_PORT = 39172; +export const JIRA_OAUTH_CALLBACK_PATH = '/oauth/jira/callback'; +export const VENUS_PROTOCOL_JIRA_PROJECT_KEY = 'VPD'; +export const MCP_PROTOCOL_VERSION = '2025-06-18'; diff --git a/.pi/extensions/setup/index.ts b/.pi/extensions/setup/index.ts new file mode 100644 index 0000000000..5bac259183 --- /dev/null +++ b/.pi/extensions/setup/index.ts @@ -0,0 +1,6 @@ +import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; +import { SetupExtension } from './SetupExtension'; + +export default function setup(pi: ExtensionAPI) { + new SetupExtension(pi).register(); +} diff --git a/.pi/extensions/setup/types.ts b/.pi/extensions/setup/types.ts new file mode 100644 index 0000000000..a2390ca936 --- /dev/null +++ b/.pi/extensions/setup/types.ts @@ -0,0 +1,141 @@ +export type ProviderName = 'github' | 'notion' | 'jira'; + +export type ProviderMetadata = { + connectedAtMs: number; + accountName?: string; + scopes?: string[]; + url?: string; +}; + +export type SetupState = { + version: number; + promptDismissed: boolean; + providers: Partial>; +}; + +export type StoredCredentials = { + github?: { token: string }; + jira?: { + token: string; + clientId: string; + cloudId: string; + siteUrl: string; + projectKeys?: string[]; + refreshToken?: string; + expiresAtMs?: number; + }; +}; + +export type CommandResult = { + code: number; + stdout: string; + stderr: string; +}; + +export type GitHubSearchRepositoriesParams = { + query: string; + limit?: number; +}; + +export type GitHubSearchIssuesParams = { + query: string; + repo?: string; + limit?: number; +}; + +export type GitHubReadFileParams = { + owner: string; + repo: string; + path: string; + ref?: string; +}; + +export type NotionSearchPagesParams = { + query: string; + limit?: number; +}; + +export type NotionReadPageParams = { + pageId: string; +}; + +export type NotionUserMeResponse = { + id?: string; + name?: string; + workspace_name?: string; + workspace_id?: string; + bot?: { + workspace_name?: string; + workspace_id?: string; + }; + person?: { + email?: string; + }; +}; + +export type NotionCliApiResult = { ok: true; payload: unknown } | { ok: false; message: string }; + +export type JiraSearchIssuesParams = { + jql: string; + limit?: number; +}; + +export type JiraReadIssueParams = { + issueKey: string; +}; + +export type JiraOAuthTokenResponse = { + access_token?: string; + refresh_token?: string; + token_type?: string; + expires_in?: number; + scope?: string; + error?: string; + error_description?: string; +}; + +export type JiraOAuthMetadata = { + authorization_endpoint: string; + token_endpoint: string; + registration_endpoint?: string; +}; + +export type JiraOAuthClientRegistrationResponse = { + client_id?: string; + error?: string; + error_description?: string; +}; + +export type OAuthCallbackServer = { + redirectUri: string; + waitForCode: Promise; + close: () => Promise; +}; + +export type JiraMcpMessage = { + jsonrpc?: string; + id?: number | string; + result?: unknown; + error?: { code?: number; message?: string; data?: unknown }; +}; + +export type JiraMcpRequestResult = { + message?: JiraMcpMessage; + sessionId?: string; +}; + +export type JiraMcpToolResult = { + content?: Array<{ type?: string; text?: string }>; + isError?: boolean; +}; + +export type JiraMcpResource = { + id: string; + name?: string; + url?: string; +}; + +export type JiraMcpProject = { + key: string; + name?: string; +}; diff --git a/.pi/extensions/setup/utils/errorMessage/index.ts b/.pi/extensions/setup/utils/errorMessage/index.ts new file mode 100644 index 0000000000..fc21c8d003 --- /dev/null +++ b/.pi/extensions/setup/utils/errorMessage/index.ts @@ -0,0 +1,7 @@ +export const errorMessage = (error: unknown) => { + if (error instanceof Error) { + return error.message; + } + + return String(error); +}; diff --git a/.pi/extensions/setup/utils/expiresAtMs/index.ts b/.pi/extensions/setup/utils/expiresAtMs/index.ts new file mode 100644 index 0000000000..78361ba5cc --- /dev/null +++ b/.pi/extensions/setup/utils/expiresAtMs/index.ts @@ -0,0 +1,7 @@ +export const expiresAtMs = (expiresInSeconds: number | undefined) => { + if (!expiresInSeconds) { + return undefined; + } + + return Date.now() + expiresInSeconds * 1000; +}; diff --git a/.pi/extensions/setup/utils/formatSize/index.ts b/.pi/extensions/setup/utils/formatSize/index.ts new file mode 100644 index 0000000000..419fe4a91a --- /dev/null +++ b/.pi/extensions/setup/utils/formatSize/index.ts @@ -0,0 +1,11 @@ +export const formatSize = (bytes: number) => { + if (bytes < 1024) { + return `${bytes}B`; + } + + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)}KB`; + } + + return `${(bytes / 1024 / 1024).toFixed(1)}MB`; +}; diff --git a/.pi/extensions/setup/utils/index.ts b/.pi/extensions/setup/utils/index.ts new file mode 100644 index 0000000000..b5efa3cb66 --- /dev/null +++ b/.pi/extensions/setup/utils/index.ts @@ -0,0 +1,15 @@ +export { errorMessage } from './errorMessage'; +export { expiresAtMs } from './expiresAtMs'; +export { formatSize } from './formatSize'; +export { limit } from './limit'; +export { openBrowser } from './openBrowser'; +export { parseScopes } from './parseScopes'; +export { pkceChallenge } from './pkceChallenge'; +export { randomUrlSafeString } from './randomUrlSafeString'; +export { readJsonResponse } from './readJsonResponse'; +export { runCommand } from './runCommand'; +export { runInteractiveCommand } from './runInteractiveCommand'; +export { runInteractiveShellCommand } from './runInteractiveShellCommand'; +export { textResult } from './textResult'; +export { truncate } from './truncate'; +export { unconfiguredResult } from './unconfiguredResult'; diff --git a/.pi/extensions/setup/utils/limit/index.ts b/.pi/extensions/setup/utils/limit/index.ts new file mode 100644 index 0000000000..0cb9c48cb2 --- /dev/null +++ b/.pi/extensions/setup/utils/limit/index.ts @@ -0,0 +1,7 @@ +export const limit = (value: number | undefined) => { + if (!value || value < 1) { + return 10; + } + + return Math.min(Math.floor(value), 20); +}; diff --git a/.pi/extensions/setup/utils/openBrowser/index.ts b/.pi/extensions/setup/utils/openBrowser/index.ts new file mode 100644 index 0000000000..917e341ff9 --- /dev/null +++ b/.pi/extensions/setup/utils/openBrowser/index.ts @@ -0,0 +1,18 @@ +import { spawn } from 'node:child_process'; + +export const openBrowser = (url: string) => { + let command = 'xdg-open'; + let args = [url]; + + if (process.platform === 'darwin') { + command = 'open'; + } + + if (process.platform === 'win32') { + command = 'cmd'; + args = ['/c', 'start', '', url]; + } + + const child = spawn(command, args, { detached: true, stdio: 'ignore' }); + child.unref(); +}; diff --git a/.pi/extensions/setup/utils/parseScopes/index.ts b/.pi/extensions/setup/utils/parseScopes/index.ts new file mode 100644 index 0000000000..925a273436 --- /dev/null +++ b/.pi/extensions/setup/utils/parseScopes/index.ts @@ -0,0 +1,5 @@ +export const parseScopes = (scopes: string) => + scopes + .split(/[\s,]+/) + .map(scope => scope.trim()) + .filter(Boolean); diff --git a/.pi/extensions/setup/utils/pkceChallenge/index.ts b/.pi/extensions/setup/utils/pkceChallenge/index.ts new file mode 100644 index 0000000000..651034bc2a --- /dev/null +++ b/.pi/extensions/setup/utils/pkceChallenge/index.ts @@ -0,0 +1,4 @@ +import { createHash } from 'node:crypto'; + +export const pkceChallenge = (codeVerifier: string) => + createHash('sha256').update(codeVerifier).digest('base64url'); diff --git a/.pi/extensions/setup/utils/randomUrlSafeString/index.ts b/.pi/extensions/setup/utils/randomUrlSafeString/index.ts new file mode 100644 index 0000000000..bbccd96dd9 --- /dev/null +++ b/.pi/extensions/setup/utils/randomUrlSafeString/index.ts @@ -0,0 +1,4 @@ +import { randomBytes } from 'node:crypto'; + +export const randomUrlSafeString = (byteLength = 32) => + randomBytes(byteLength).toString('base64url'); diff --git a/.pi/extensions/setup/utils/readJsonResponse/index.ts b/.pi/extensions/setup/utils/readJsonResponse/index.ts new file mode 100644 index 0000000000..22d8aeedda --- /dev/null +++ b/.pi/extensions/setup/utils/readJsonResponse/index.ts @@ -0,0 +1,14 @@ +import { truncate } from '../truncate'; + +export const readJsonResponse = async (response: Response, label: string) => { + const text = await response.text(); + if (!response.ok) { + throw new Error(`${label} failed: ${response.status} ${response.statusText} ${truncate(text)}`); + } + + if (!text.trim()) { + return {}; + } + + return JSON.parse(text) as unknown; +}; diff --git a/.pi/extensions/setup/utils/runCommand/index.ts b/.pi/extensions/setup/utils/runCommand/index.ts new file mode 100644 index 0000000000..77fd1cb13b --- /dev/null +++ b/.pi/extensions/setup/utils/runCommand/index.ts @@ -0,0 +1,28 @@ +import { spawn } from 'node:child_process'; +import type { CommandResult } from '../../types'; + +export const runCommand = (command: string, args: string[]): Promise => + new Promise(resolveCommand => { + let stdout = ''; + let stderr = ''; + let settled = false; + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + + child.stdout?.on('data', chunk => { + stdout += String(chunk); + }); + child.stderr?.on('data', chunk => { + stderr += String(chunk); + }); + child.on('error', error => { + settled = true; + resolveCommand({ code: 1, stdout, stderr: error.message }); + }); + child.on('close', code => { + if (settled) { + return; + } + + resolveCommand({ code: code ?? 1, stdout, stderr }); + }); + }); diff --git a/.pi/extensions/setup/utils/runInteractiveCommand/index.ts b/.pi/extensions/setup/utils/runInteractiveCommand/index.ts new file mode 100644 index 0000000000..ab7abefe10 --- /dev/null +++ b/.pi/extensions/setup/utils/runInteractiveCommand/index.ts @@ -0,0 +1,19 @@ +import { spawn } from 'node:child_process'; + +export const runInteractiveCommand = (command: string, args: string[]): Promise => + new Promise((resolveCommand, rejectCommand) => { + const child = spawn(command, args, { stdio: 'inherit' }); + + child.on('error', error => { + rejectCommand(new Error(`${command} failed to start: ${error.message}`)); + }); + child.on('close', code => { + const exitCode = code ?? 1; + if (exitCode === 0) { + resolveCommand(); + return; + } + + rejectCommand(new Error(`${command} exited with code ${exitCode}`)); + }); + }); diff --git a/.pi/extensions/setup/utils/runInteractiveShellCommand/index.ts b/.pi/extensions/setup/utils/runInteractiveShellCommand/index.ts new file mode 100644 index 0000000000..22a89eb61e --- /dev/null +++ b/.pi/extensions/setup/utils/runInteractiveShellCommand/index.ts @@ -0,0 +1,4 @@ +import { runInteractiveCommand } from '../runInteractiveCommand'; + +export const runInteractiveShellCommand = (command: string) => + runInteractiveCommand('sh', ['-lc', command]); diff --git a/.pi/extensions/setup/utils/textResult/index.ts b/.pi/extensions/setup/utils/textResult/index.ts new file mode 100644 index 0000000000..34f59d25c8 --- /dev/null +++ b/.pi/extensions/setup/utils/textResult/index.ts @@ -0,0 +1,4 @@ +export const textResult = (text: string) => ({ + content: [{ type: 'text' as const, text }], + details: {}, +}); diff --git a/.pi/extensions/setup/utils/truncate/index.ts b/.pi/extensions/setup/utils/truncate/index.ts new file mode 100644 index 0000000000..44f30fc033 --- /dev/null +++ b/.pi/extensions/setup/utils/truncate/index.ts @@ -0,0 +1,26 @@ +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from '../../constants'; +import { formatSize } from '../formatSize'; + +export const truncate = (text: string) => { + const lines = text.split('\n'); + let bytes = 0; + const outputLines: string[] = []; + + for (const line of lines) { + const lineBytes = Buffer.byteLength(`${line}\n`, 'utf8'); + if (outputLines.length >= DEFAULT_MAX_LINES || bytes + lineBytes > DEFAULT_MAX_BYTES) { + break; + } + + outputLines.push(line); + bytes += lineBytes; + } + + if (outputLines.length === lines.length && bytes <= DEFAULT_MAX_BYTES) { + return text; + } + + return `${outputLines.join('\n')}\n\n[Output truncated: ${outputLines.length} of ${ + lines.length + } lines (${formatSize(bytes)} of ${formatSize(Buffer.byteLength(text, 'utf8'))}).]`; +}; diff --git a/.pi/extensions/setup/utils/unconfiguredResult/index.ts b/.pi/extensions/setup/utils/unconfiguredResult/index.ts new file mode 100644 index 0000000000..c17213aa58 --- /dev/null +++ b/.pi/extensions/setup/utils/unconfiguredResult/index.ts @@ -0,0 +1,4 @@ +import { textResult } from '../textResult'; + +export const unconfiguredResult = (provider: string) => + textResult(`${provider} is not connected. Run /setup to configure it.`); diff --git a/AGENTS.md b/AGENTS.md index 373e5566dc..db15e356f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,3 +38,16 @@ Keep changes small, focused, and aligned with existing code patterns. - Keep data fetching/mutations in `apps/evm/src/clients/api` hooks. - Preserve existing behavior while applying visual fixes. - Do not change formula/data-flow logic unless explicitly required by plan. + +## External knowledge sources + +When relevant, use connected read-only project knowledge sources if the user has configured them: + +- GitHub: look up related issues, pull requests, discussions, and upstream repository files. +- Notion: look up product specs, architecture notes, operational docs, and project documentation. +- Jira: look up feature tickets, requirements, acceptance criteria, and implementation context. + +Use these sources when working on features, answering project questions, investigating how contracts +or protocol flows work, or clarifying historical decisions. Prefer local repository code as the +source of truth for implementation details. If an integration is unavailable, continue with the +available local context and mention the limitation only when it matters. diff --git a/README.md b/README.md index 56f1d35d3d..e830ed034c 100644 --- a/README.md +++ b/README.md @@ -88,26 +88,3 @@ For example: Runs translation extraction, fills entries marked `TRANSLATION NEEDED` from the English source, and updates other locales when English copy changed on the current branch. - -## UI Development Skills - -### ui-develop - -Build new UI features from Figma designs (phases 1-2: Plan, Code). - -```ssh -/ui-develop -``` - -### ui-i18n -```ssh -/ui-i18n [feature-name] -``` - -### ui-qa - -Run QA pipeline for UI features (phases 4-6: Preview, Review, Fix). Auto-detects the most recent feature if not specified. - -```ssh -/ui-qa [feature-name] -```