diff --git a/apps/cli/README.md b/apps/cli/README.md index 04721e39..1dda8332 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -161,7 +161,7 @@ Download the official OpenCode MCP config template and add your API key: curl -fsSL https://btca.dev/opencode-mcp.json -o opencode.json ``` -Then replace `YOUR_API_KEY` with your MCP API key (see `btca remote mcp` or the web dashboard). +Then replace `YOUR_API_KEY` with your MCP API key from the web dashboard. ## TUI Commands diff --git a/apps/cli/src/client/index.ts b/apps/cli/src/client/index.ts index 909e4f37..515d0623 100644 --- a/apps/cli/src/client/index.ts +++ b/apps/cli/src/client/index.ts @@ -143,12 +143,18 @@ export type ProviderOptionsInput = { name?: string; }; +export type ModelUpdateResult = { + provider: string; + model: string; + savedTo: 'project' | 'global'; +}; + export async function updateModel( baseUrl: string, provider: string, model: string, providerOptions?: ProviderOptionsInput -): Promise<{ provider: string; model: string }> { +): Promise { const res = await fetch(`${baseUrl}/config/model`, { method: 'PUT', headers: { @@ -165,7 +171,7 @@ export async function updateModel( throw await parseErrorResponse(res, `Failed to update model: ${res.status}`); } - return res.json() as Promise<{ provider: string; model: string }>; + return res.json() as Promise; } export interface GitResourceInput { diff --git a/apps/cli/src/client/remote.ts b/apps/cli/src/client/remote.ts deleted file mode 100644 index 2e0420dd..00000000 --- a/apps/cli/src/client/remote.ts +++ /dev/null @@ -1,581 +0,0 @@ -/** - * Remote API client for btca cloud service. - * Communicates with the web app's API endpoints via the MCP protocol. - */ - -import { Result } from 'better-result'; - -// TODO: Change back to 'https://btca.dev' before deploying! -const DEFAULT_REMOTE_URL = 'https://btca.dev'; - -// Local type definitions to avoid circular dependencies -export interface GitResource { - type: 'git'; - name: string; - url: string; - branch: string; - searchPath?: string; - searchPaths?: string[]; - specialNotes?: string; -} - -export interface RemoteConfig { - $schema?: string; - project: string; - model?: 'claude-sonnet' | 'claude-haiku' | 'gpt-4o' | 'gpt-4o-mini'; - resources: GitResource[]; -} - -export interface RemoteClientOptions { - apiKey: string; - baseUrl?: string; -} - -export interface RemoteResource { - name: string; - displayName: string; - type: string; - url: string; - branch: string; - searchPath?: string; - specialNotes?: string; - isGlobal: boolean; -} - -export interface RemoteProject { - _id: string; - name: string; - model?: string; - isDefault: boolean; - createdAt: number; -} - -export interface RemoteInstance { - _id: string; - state: - | 'unprovisioned' - | 'provisioning' - | 'stopped' - | 'starting' - | 'running' - | 'stopping' - | 'updating' - | 'error'; - serverUrl?: string; - btcaVersion?: string; - subscriptionPlan?: 'pro' | 'free' | 'none'; -} - -export interface RemoteThread { - _id: string; - title?: string; - createdAt: number; - lastActivityAt: number; -} - -export interface RemoteMessage { - _id: string; - threadId: string; - role: 'user' | 'assistant' | 'system'; - content: string; - resources?: string[]; - createdAt: number; -} - -export interface McpQuestion { - _id: string; - projectId: string; - question: string; - resources: string[]; - answer: string; - createdAt: number; -} - -export interface SyncResult { - ok: boolean; - errors?: string[]; - synced: string[]; - conflicts?: Array<{ - name: string; - local: GitResource; - remote: RemoteResource; - }>; -} - -export class RemoteApiError extends Error { - readonly statusCode?: number; - readonly hint?: string; - - constructor(message: string, options?: { statusCode?: number; hint?: string }) { - super(message); - this.name = 'RemoteApiError'; - this.statusCode = options?.statusCode; - this.hint = options?.hint; - } -} - -const parseJsonText = (text: string): unknown | null => { - const result = Result.try(() => JSON.parse(text)); - return Result.isOk(result) ? result.value : null; -}; - -const parseErrorText = (text: string) => { - const parsed = parseJsonText(text) as { error?: string } | null; - return parsed?.error ?? text; -}; - -const getRemoteApiError = (error: unknown): RemoteApiError | null => { - if (error instanceof RemoteApiError) return error; - if (error && typeof error === 'object' && 'cause' in error) { - const cause = (error as { cause?: unknown }).cause; - if (cause instanceof RemoteApiError) return cause; - } - return null; -}; - -/** - * Remote API client - */ -export class RemoteClient { - private readonly apiKey: string; - private readonly baseUrl: string; - - constructor(options: RemoteClientOptions) { - this.apiKey = options.apiKey; - this.baseUrl = options.baseUrl ?? DEFAULT_REMOTE_URL; - } - - private async request(path: string, options: RequestInit = {}): Promise { - const url = `${this.baseUrl}${path}`; - const headers = new Headers(options.headers); - headers.set('Authorization', `Bearer ${this.apiKey}`); - headers.set('Content-Type', 'application/json'); - - const response = await fetch(url, { - ...options, - headers - }); - - if (!response.ok) { - let errorMessage = `Request failed: ${response.status}`; - let hint: string | undefined; - - const bodyResult = await Result.tryPromise(() => response.json()); - bodyResult.match({ - ok: (body) => { - const parsed = body as { error?: string; hint?: string }; - if (parsed.error) errorMessage = parsed.error; - if (parsed.hint) hint = parsed.hint; - }, - err: () => undefined - }); - - throw new RemoteApiError(errorMessage, { - statusCode: response.status, - hint - }); - } - - const contentType = response.headers.get('content-type') ?? ''; - - if (contentType.includes('text/event-stream')) { - const text = await response.text(); - const dataLine = text.split('\n').find((line) => line.startsWith('data: ')); - if (!dataLine) { - throw new RemoteApiError('No data in SSE response'); - } - const parsed = parseJsonText(dataLine.slice(6)) as T | null; - if (!parsed) throw new RemoteApiError('Failed to parse SSE response'); - return parsed; - } - - return response.json() as Promise; - } - - /** - * Validate the API key and get basic info - */ - async validate(): Promise<{ valid: boolean; error?: string }> { - const result = await Result.tryPromise(() => this.listResources()); - if (Result.isOk(result)) return { valid: result.value.ok }; - const remoteError = getRemoteApiError(result.error); - if (remoteError?.statusCode === 401) { - return { valid: false, error: 'Invalid or expired API key' }; - } - throw result.error; - } - - /** - * List available resources via MCP - */ - async listResources(project?: string): Promise< - | { - ok: true; - resources: RemoteResource[]; - } - | { - ok: false; - error: string; - } - > { - // MCP uses JSON-RPC, we need to call the tools/call endpoint - const mcpRequest = { - jsonrpc: '2.0', - id: Date.now(), - method: 'tools/call', - params: { - name: 'listResources', - arguments: project ? { project } : {} - } - }; - - const response = await this.request<{ - result?: { - content: Array<{ type: string; text: string }>; - isError?: boolean; - }; - error?: { message: string }; - }>('/api/mcp', { - method: 'POST', - body: JSON.stringify(mcpRequest) - }); - - if (response.error) { - return { ok: false, error: response.error.message }; - } - - if (response.result?.isError) { - const errorText = response.result.content[0]?.text ?? 'Unknown error'; - return { ok: false, error: parseErrorText(errorText) }; - } - - const text = response.result?.content[0]?.text ?? '[]'; - const resources = parseJsonText(text) as RemoteResource[] | null; - return resources ? { ok: true, resources } : { ok: false, error: 'Failed to parse resources' }; - } - - /** - * Ask a question via MCP - */ - async ask( - question: string, - resources: string[], - project?: string - ): Promise< - | { - ok: true; - text: string; - } - | { - ok: false; - error: string; - } - > { - const mcpRequest = { - jsonrpc: '2.0', - id: Date.now(), - method: 'tools/call', - params: { - name: 'ask', - arguments: { - question, - resources, - ...(project && { project }) - } - } - }; - - const response = await this.request<{ - result?: { - content: Array<{ type: string; text: string }>; - isError?: boolean; - }; - error?: { message: string }; - }>('/api/mcp', { - method: 'POST', - body: JSON.stringify(mcpRequest) - }); - - if (response.error) { - return { ok: false, error: response.error.message }; - } - - if (response.result?.isError) { - const errorText = response.result.content[0]?.text ?? 'Unknown error'; - return { ok: false, error: parseErrorText(errorText) }; - } - - return { - ok: true, - text: response.result?.content[0]?.text ?? '' - }; - } - - /** - * Add a resource via MCP - */ - async addResource( - resource: GitResource, - project?: string - ): Promise< - | { - ok: true; - resource: RemoteResource; - } - | { - ok: false; - error: string; - } - > { - const mcpRequest = { - jsonrpc: '2.0', - id: Date.now(), - method: 'tools/call', - params: { - name: 'addResource', - arguments: { - url: resource.url, - name: resource.name, - branch: resource.branch, - ...(resource.searchPath && { searchPath: resource.searchPath }), - ...(resource.searchPaths && { searchPaths: resource.searchPaths }), - ...(resource.specialNotes && { notes: resource.specialNotes }), - ...(project && { project }) - } - } - }; - - const response = await this.request<{ - result?: { - content: Array<{ type: string; text: string }>; - isError?: boolean; - }; - error?: { message: string }; - }>('/api/mcp', { - method: 'POST', - body: JSON.stringify(mcpRequest) - }); - - if (response.error) { - return { ok: false, error: response.error.message }; - } - - if (response.result?.isError) { - const errorText = response.result.content[0]?.text ?? 'Unknown error'; - return { ok: false, error: parseErrorText(errorText) }; - } - - const text = response.result?.content[0]?.text ?? '{}'; - const parsed = parseJsonText(text) as RemoteResource | null; - return parsed - ? { ok: true, resource: parsed } - : { ok: true, resource: { name: resource.name } as RemoteResource }; - } - - /** - * Sync config with cloud - */ - async sync(config: RemoteConfig, force?: boolean): Promise { - const mcpRequest = { - jsonrpc: '2.0', - id: Date.now(), - method: 'tools/call', - params: { - name: 'sync', - arguments: { - config: JSON.stringify(config), - force: force ?? false - } - } - }; - - const response = await this.request<{ - result?: { - content: Array<{ type: string; text: string }>; - isError?: boolean; - }; - error?: { message: string }; - }>('/api/mcp', { - method: 'POST', - body: JSON.stringify(mcpRequest) - }); - - if (response.error) { - return { ok: false, errors: [response.error.message], synced: [] }; - } - - if (response.result?.isError) { - const errorText = response.result.content[0]?.text ?? 'Unknown error'; - return { ok: false, errors: [parseErrorText(errorText)], synced: [] }; - } - - const text = response.result?.content[0]?.text ?? '{}'; - const parsed = parseJsonText(text) as SyncResult | null; - return parsed ?? { ok: true, synced: [] }; - } - - /** - * Get instance status via the CLI API - */ - async getStatus(project?: string): Promise< - | { - ok: true; - instance: RemoteInstance; - project?: RemoteProject; - } - | { - ok: false; - error: string; - } - > { - const result = await Result.tryPromise(() => - this.request<{ instance: RemoteInstance; project?: RemoteProject }>( - `/api/cli/status${project ? `?project=${encodeURIComponent(project)}` : ''}` - ) - ); - if (Result.isOk(result)) { - return { ok: true, ...result.value }; - } - const remoteError = getRemoteApiError(result.error); - if (remoteError) return { ok: false, error: remoteError.message }; - throw result.error; - } - - /** - * Wake the sandbox via the CLI API - */ - async wake(): Promise< - | { - ok: true; - serverUrl: string; - } - | { - ok: false; - error: string; - } - > { - const result = await Result.tryPromise(() => - this.request<{ serverUrl: string }>('/api/cli/wake', { method: 'POST' }) - ); - if (Result.isOk(result)) { - return { ok: true, serverUrl: result.value.serverUrl }; - } - const remoteError = getRemoteApiError(result.error); - if (remoteError) return { ok: false, error: remoteError.message }; - throw result.error; - } - - /** - * Get thread transcript via the CLI API - */ - async getThread(threadId: string): Promise< - | { - ok: true; - thread: RemoteThread; - messages: RemoteMessage[]; - } - | { - ok: false; - error: string; - } - > { - const result = await Result.tryPromise(() => - this.request<{ thread: RemoteThread; messages: RemoteMessage[] }>( - `/api/cli/threads/${threadId}` - ) - ); - if (Result.isOk(result)) { - return { ok: true, ...result.value }; - } - const remoteError = getRemoteApiError(result.error); - if (remoteError) return { ok: false, error: remoteError.message }; - throw result.error; - } - - /** - * List threads via the CLI API - */ - async listThreads(project?: string): Promise< - | { - ok: true; - threads: RemoteThread[]; - } - | { - ok: false; - error: string; - } - > { - const result = await Result.tryPromise(() => - this.request<{ threads: RemoteThread[] }>( - `/api/cli/threads${project ? `?project=${encodeURIComponent(project)}` : ''}` - ) - ); - if (Result.isOk(result)) { - return { ok: true, threads: result.value.threads }; - } - const remoteError = getRemoteApiError(result.error); - if (remoteError) return { ok: false, error: remoteError.message }; - throw result.error; - } - - /** - * List MCP questions for a project via the CLI API - */ - async listQuestions(project: string): Promise< - | { - ok: true; - questions: McpQuestion[]; - } - | { - ok: false; - error: string; - } - > { - const result = await Result.tryPromise(() => - this.request<{ questions: McpQuestion[] }>( - `/api/cli/questions?project=${encodeURIComponent(project)}` - ) - ); - if (Result.isOk(result)) { - return { ok: true, questions: result.value.questions }; - } - const remoteError = getRemoteApiError(result.error); - if (remoteError) return { ok: false, error: remoteError.message }; - throw result.error; - } - - /** - * List projects via the CLI API - */ - async listProjects(): Promise< - | { - ok: true; - projects: RemoteProject[]; - } - | { - ok: false; - error: string; - } - > { - const result = await Result.tryPromise(() => - this.request<{ projects: RemoteProject[] }>('/api/cli/projects') - ); - if (Result.isOk(result)) { - return { ok: true, projects: result.value.projects }; - } - const remoteError = getRemoteApiError(result.error); - if (remoteError) return { ok: false, error: remoteError.message }; - throw result.error; - } -} - -/** - * Create a remote client from stored auth - */ -export async function createRemoteClientFromAuth( - loadAuth: () => Promise<{ apiKey: string } | null> -): Promise { - const auth = await loadAuth(); - if (!auth) return null; - return new RemoteClient({ apiKey: auth.apiKey }); -} diff --git a/apps/cli/src/commands/ask.ts b/apps/cli/src/commands/ask.ts index 2bf6f5f7..077e56d4 100644 --- a/apps/cli/src/commands/ask.ts +++ b/apps/cli/src/commands/ask.ts @@ -254,8 +254,16 @@ export const askCommand = new Command('ask') } console.log(`[${tool}]`); }, - onError: (message) => { + onError: (message, tag, hint) => { console.error(`\nError: ${message}`); + if (hint) { + console.error(`\nHint: ${hint}`); + } else if ( + tag === 'ProviderNotAuthenticatedError' || + message === 'Unhandled exception: Provider "opencode" is not authenticated.' + ) { + console.error('\nHint: run btca connect to authenticate and pick a model.'); + } } }); } @@ -305,7 +313,7 @@ interface StreamHandlers { onReasoningDelta?: (delta: string) => void; onTextDelta?: (delta: string) => void; onToolCall?: (tool: string) => void; - onError?: (message: string) => void; + onError?: (message: string, tag?: string, hint?: string) => void; } function handleStreamEvent(event: BtcaStreamEvent, handlers: StreamHandlers): void { @@ -325,7 +333,7 @@ function handleStreamEvent(event: BtcaStreamEvent, handlers: StreamHandlers): vo } break; case 'error': - handlers.onError?.(event.message); + handlers.onError?.(event.message, event.tag, event.hint); break; case 'done': break; diff --git a/apps/cli/src/commands/connect.ts b/apps/cli/src/commands/connect.ts index 2dc83250..d6ecaf73 100644 --- a/apps/cli/src/commands/connect.ts +++ b/apps/cli/src/commands/connect.ts @@ -329,7 +329,7 @@ export const connectCommand = new Command('connect') const result = await updateModel(server.url, provider, modelId, { baseURL, name }); console.log(`\nModel configured: ${result.provider}/${result.model}`); - console.log(`\nSaved to: ${options.global ? 'global' : 'project'} config`); + console.log(`\nSaved to: ${result.savedTo} config`); server.stop(); return; } @@ -373,7 +373,7 @@ export const connectCommand = new Command('connect') console.log(`\nModel configured: ${result.provider}/${result.model}`); // Show where it was saved - console.log(`\nSaved to: ${options.global ? 'global' : 'project'} config`); + console.log(`\nSaved to: ${result.savedTo} config`); server.stop(); }); diff --git a/apps/cli/src/commands/init.ts b/apps/cli/src/commands/init.ts index fb7fb538..9f0bc49e 100644 --- a/apps/cli/src/commands/init.ts +++ b/apps/cli/src/commands/init.ts @@ -4,23 +4,14 @@ import select from '@inquirer/select'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import * as readline from 'readline'; -import { loadAuth, saveAuth } from '../lib/auth.ts'; -import { RemoteClient } from '../client/remote.ts'; const PROJECT_CONFIG_FILENAME = 'btca.config.jsonc'; -const REMOTE_CONFIG_FILENAME = 'btca.remote.config.jsonc'; const CONFIG_SCHEMA_URL = 'https://btca.dev/btca.schema.json'; -const REMOTE_CONFIG_SCHEMA_URL = 'https://btca.dev/btca.remote.schema.json'; const DEFAULT_MODEL = 'claude-haiku-4-5'; const DEFAULT_PROVIDER = 'opencode'; -const MCP_API_KEY_URL = 'https://btca.dev/app/settings?tab=mcp'; -type SetupType = 'mcp' | 'cli'; type StorageType = 'local' | 'global'; -/** - * Prompt user for single selection. - */ async function promptSelect( question: string, options: { label: string; value: T }[] @@ -37,15 +28,15 @@ async function promptSelect( }); console.log(`\n${question}\n`); - options.forEach((opt, idx) => { - console.log(` ${idx + 1}) ${opt.label}`); + options.forEach((option, index) => { + console.log(` ${index + 1}) ${option.label}`); }); console.log(''); rl.question('Enter number: ', (answer) => { rl.close(); - const num = parseInt(answer.trim(), 10); - if (isNaN(num) || num < 1 || num > options.length) { + const num = Number.parseInt(answer.trim(), 10); + if (!Number.isFinite(num) || num < 1 || num > options.length) { reject(new Error('Invalid selection')); return; } @@ -64,47 +55,20 @@ async function promptSelect( return selection as T; } -/** - * Prompt user for input with optional default value. - */ -async function promptInput(question: string, defaultValue?: string): Promise { - return new Promise((resolve) => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout - }); - - const defaultHint = defaultValue ? ` (${defaultValue})` : ''; - rl.question(`${question}${defaultHint}: `, (answer) => { - rl.close(); - resolve(answer.trim() || defaultValue || ''); - }); - }); -} - -/** - * Check if a pattern is already in .gitignore (handles variations like .btca, .btca/, .btca/*). - */ async function isPatternInGitignore(dir: string, pattern: string): Promise { const gitignorePath = path.join(dir, '.gitignore'); const result = await Result.tryPromise(() => fs.readFile(gitignorePath, 'utf-8')); if (Result.isError(result)) return false; const lines = result.value.split('\n').map((line) => line.trim()); - - // Check for the pattern and common variations const basePattern = pattern.replace(/\/$/, ''); const patterns = [basePattern, `${basePattern}/`, `${basePattern}/*`]; return lines.some((line) => { - // Skip comments and empty lines if (line.startsWith('#') || line === '') return false; return patterns.includes(line); }); } -/** - * Add a pattern to .gitignore, creating the file if it doesn't exist. - */ async function addToGitignore(dir: string, pattern: string, comment?: string): Promise { const gitignorePath = path.join(dir, '.gitignore'); const contentResult = await Result.tryPromise(() => fs.readFile(gitignorePath, 'utf-8')); @@ -113,7 +77,6 @@ async function addToGitignore(dir: string, pattern: string, comment?: string): P content += '\n'; } - // Add comment and pattern if (comment) { content += `\n${comment}\n`; } @@ -122,145 +85,17 @@ async function addToGitignore(dir: string, pattern: string, comment?: string): P await fs.writeFile(gitignorePath, content, 'utf-8'); } -/** - * Check if directory is a git repository. - */ async function isGitRepo(dir: string): Promise { const result = await Result.tryPromise(() => fs.access(path.join(dir, '.git'))); return Result.isOk(result); } -/** - * Check if a file exists. - */ async function fileExists(filePath: string): Promise { const result = await Result.tryPromise(() => fs.access(filePath)); return Result.isOk(result); } -/** - * Sanitize a string to be used as a project name. - */ -function sanitizeProjectName(name: string): string { - return name - .toLowerCase() - .replace(/[^a-z0-9-]/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); -} - -export const initCommand = new Command('init') - .description('Initialize btca for this project') - .option('-f, --force', 'Overwrite existing configuration') - .action(async (options: { force?: boolean }) => { - const cwd = process.cwd(); - const configPath = path.join(cwd, PROJECT_CONFIG_FILENAME); - - const result = await Result.tryPromise(async () => { - // Step 1: Ask for setup type - const setupType = await promptSelect('Choose setup type:', [ - { label: 'MCP (cloud hosted resources)', value: 'mcp' }, - { label: 'CLI (local resources)', value: 'cli' } - ]); - - if (setupType === 'mcp') { - // MCP Path - await handleMcpSetup(cwd, options.force); - } else { - // CLI Path - await handleCliSetup(cwd, configPath, options.force); - } - }); - - if (Result.isError(result)) { - const error = result.error; - if (error instanceof Error && error.message === 'Invalid selection') { - console.error('\nError: Invalid selection. Please run btca init again.'); - process.exit(1); - } - console.error('Error:', error instanceof Error ? error.message : String(error)); - process.exit(1); - } - }); - -/** - * Handle MCP setup path. - */ -async function handleMcpSetup(cwd: string, force?: boolean): Promise { - const configPath = path.join(cwd, REMOTE_CONFIG_FILENAME); - - // Check for existing config - if ((await fileExists(configPath)) && !force) { - console.error(`\nError: ${REMOTE_CONFIG_FILENAME} already exists.`); - console.error('Use --force to overwrite.'); - process.exit(1); - } - - // Step 1: Check/handle authentication - let auth = await loadAuth(); - - if (!auth) { - console.log('\nNo API key found. Please get one from:'); - console.log(` ${MCP_API_KEY_URL}\n`); - - const apiKey = await promptInput('API Key'); - - if (!apiKey) { - console.error('API key is required.'); - process.exit(1); - } - - // Validate the API key - console.log('Validating API key...'); - const client = new RemoteClient({ apiKey }); - const validation = await client.validate(); - - if (!validation.valid) { - console.error(`Invalid API key: ${validation.error}`); - process.exit(1); - } - - // Save auth - auth = { apiKey, linkedAt: Date.now() }; - await saveAuth(auth); - console.log('API key saved.\n'); - } else { - console.log('\nUsing existing API key.\n'); - } - - // Step 2: Get project name - const defaultProjectName = sanitizeProjectName(path.basename(cwd)); - const projectName = await promptInput(`Project name`, defaultProjectName); - - if (!projectName) { - console.error('Project name is required.'); - process.exit(1); - } - - // Step 3: Create btca.remote.config.jsonc - const config = { - $schema: REMOTE_CONFIG_SCHEMA_URL, - project: projectName, - model: 'claude-haiku', - resources: [] as unknown[] - }; - - await fs.writeFile(configPath, JSON.stringify(config, null, '\t'), 'utf-8'); - console.log(`Created ${REMOTE_CONFIG_FILENAME}`); - - // Step 4: Print next steps - console.log('\n--- Setup Complete (MCP) ---\n'); - console.log('Next steps:'); - console.log(' 1. Add resources: btca remote add https://github.com/owner/repo'); - console.log(' 2. Or add via dashboard: https://btca.dev/app/resources'); - console.log(' 3. Query resources: btca remote ask -q "your question"'); -} - -/** - * Handle CLI setup path. - */ async function handleCliSetup(cwd: string, configPath: string, force?: boolean): Promise { - // Check if config already exists if (await fileExists(configPath)) { if (!force) { console.error(`\nError: ${PROJECT_CONFIG_FILENAME} already exists.`); @@ -270,13 +105,11 @@ async function handleCliSetup(cwd: string, configPath: string, force?: boolean): console.log(`\nOverwriting existing ${PROJECT_CONFIG_FILENAME}...`); } - // Ask for storage type const storageType = await promptSelect('Where should btca store cloned resources?', [ { label: 'Local (.btca/ in this project)', value: 'local' }, { label: 'Global (~/.local/share/btca/)', value: 'global' } ]); - // Build the config const config: Record = { $schema: CONFIG_SCHEMA_URL, model: DEFAULT_MODEL, @@ -284,23 +117,18 @@ async function handleCliSetup(cwd: string, configPath: string, force?: boolean): resources: [] }; - // Add dataDirectory if local storage chosen if (storageType === 'local') { config.dataDirectory = '.btca'; } - // Write config file - const configContent = JSON.stringify(config, null, '\t'); - await fs.writeFile(configPath, configContent, 'utf-8'); + await fs.writeFile(configPath, JSON.stringify(config, null, '\t'), 'utf-8'); console.log(`\nCreated ${PROJECT_CONFIG_FILENAME}`); - // Handle .gitignore if using local data directory if (storageType === 'local') { const inGitRepo = await isGitRepo(cwd); if (inGitRepo) { const alreadyIgnored = await isPatternInGitignore(cwd, '.btca'); - if (!alreadyIgnored) { await addToGitignore(cwd, '.btca/', '# btca local data'); console.log('Added .btca/ to .gitignore'); @@ -314,14 +142,12 @@ async function handleCliSetup(cwd: string, configPath: string, force?: boolean): } } - // Print summary if (storageType === 'local') { console.log('\nData directory: .btca/ (local to this project)'); } else { console.log('\nData directory: ~/.local/share/btca/ (global)'); } - // Print next steps console.log('\n--- Setup Complete (CLI) ---\n'); console.log('Next steps:'); console.log(' 1. Add resources: btca add https://github.com/owner/repo'); @@ -329,3 +155,25 @@ async function handleCliSetup(cwd: string, configPath: string, force?: boolean): console.log(' 3. Or launch the TUI: btca'); console.log("\nRun 'btca --help' for more options."); } + +export const initCommand = new Command('init') + .description('Initialize btca for this project') + .option('-f, --force', 'Overwrite existing configuration') + .action(async (options: { force?: boolean }) => { + const cwd = process.cwd(); + const configPath = path.join(cwd, PROJECT_CONFIG_FILENAME); + + const result = await Result.tryPromise(async () => { + await handleCliSetup(cwd, configPath, options.force); + }); + + if (Result.isError(result)) { + const error = result.error; + if (error instanceof Error && error.message === 'Invalid selection') { + console.error('\nError: Invalid selection. Please run btca init again.'); + process.exit(1); + } + console.error('Error:', error instanceof Error ? error.message : String(error)); + process.exit(1); + } + }); diff --git a/apps/cli/src/commands/mcp.ts b/apps/cli/src/commands/mcp.ts index a7078ac3..3ca5d588 100644 --- a/apps/cli/src/commands/mcp.ts +++ b/apps/cli/src/commands/mcp.ts @@ -34,13 +34,12 @@ const askSchema = z.object({ resources: z .array(z.string()) .optional() - .describe('Optional resource names to query (defaults to all local resources)') + .describe( + 'Optional resource names or HTTPS GitHub repository URLs to query (defaults to all local resources)' + ) }); type AskInput = z.infer; -const MCP_REMOTE_URL = 'https://btca.dev/api/mcp'; -const MCP_API_KEY_URL = 'https://btca.dev/app/settings?tab=mcp'; -const API_KEY_PLACEHOLDER = 'btca_xxxxxxxxx'; const LOCAL_COMMAND = ['bunx', 'btca', 'mcp']; const MCP_EDITORS = [ @@ -51,7 +50,6 @@ const MCP_EDITORS = [ ] as const; type McpEditor = (typeof MCP_EDITORS)[number]['id']; -type McpMode = 'local' | 'remote'; const promptSelectNumeric = ( question: string, @@ -188,7 +186,6 @@ const upsertTomlSection = (content: string, header: string, newKeys: Map { +const writeCodexConfig = async () => { const codexDir = path.join(process.cwd(), '.codex'); const filePath = path.join(codexDir, 'config.toml'); await ensureDir(codexDir); const file = Bun.file(filePath); const content = (await file.exists()) ? await file.text() : ''; - const header = mode === 'local' ? '[mcp_servers.btca_local]' : '[mcp_servers.btca]'; - const newKeys = - mode === 'local' - ? new Map([ - ['command', '"bunx"'], - ['args', '["btca", "mcp"]'] - ]) - : new Map([ - ['url', `"${MCP_REMOTE_URL}"`], - ['bearer_token_env_var', '"BTCA_API_KEY"'], - ['enabled', 'true'] - ]); - - const next = upsertTomlSection(content, header, newKeys); + const next = upsertTomlSection( + content, + '[mcp_servers.btca_local]', + new Map([ + ['command', '"bunx"'], + ['args', '["btca", "mcp"]'] + ]) + ); + await Bun.write(filePath, next); return filePath; }; -const writeCursorConfig = async (mode: McpMode) => { +const writeCursorConfig = async () => { const filePath = path.join(process.cwd(), '.cursor', 'mcp.json'); - const serverName = mode === 'local' ? 'btca-local' : 'btca'; - const entry = - mode === 'local' - ? { command: LOCAL_COMMAND[0], args: LOCAL_COMMAND.slice(1) } - : { - url: MCP_REMOTE_URL, - headers: { - Authorization: `Bearer ${API_KEY_PLACEHOLDER}` - } - }; - - return updateJsonConfig(filePath, (current) => upsertMcpServers(current, serverName, entry)); + const entry = { command: LOCAL_COMMAND[0], args: LOCAL_COMMAND.slice(1) }; + return updateJsonConfig(filePath, (current) => upsertMcpServers(current, 'btca-local', entry)); }; -const writeOpenCodeConfig = async (mode: McpMode) => { +const writeOpenCodeConfig = async () => { const filePath = path.join(process.cwd(), 'opencode.json'); - const serverName = mode === 'local' ? 'btca-local' : 'btca'; - const entry = - mode === 'local' - ? { - type: 'local', - command: LOCAL_COMMAND, - enabled: true - } - : { - type: 'remote', - url: MCP_REMOTE_URL, - enabled: true, - headers: { - Authorization: `Bearer ${API_KEY_PLACEHOLDER}` - } - }; - - return updateJsonConfig(filePath, (current) => upsertOpenCode(current, serverName, entry)); + const entry = { + type: 'local', + command: LOCAL_COMMAND, + enabled: true + }; + return updateJsonConfig(filePath, (current) => upsertOpenCode(current, 'btca-local', entry)); }; -const writeClaudeConfig = async (mode: McpMode) => { +const writeClaudeConfig = async () => { const filePath = path.join(process.cwd(), '.mcp.json'); - const serverName = mode === 'local' ? 'btca-local' : 'btca'; - const entry = - mode === 'local' - ? { - type: 'stdio', - command: LOCAL_COMMAND[0], - args: LOCAL_COMMAND.slice(1) - } - : { - type: 'http', - url: MCP_REMOTE_URL, - headers: { - Authorization: `Bearer ${API_KEY_PLACEHOLDER}` - } - }; - - return updateJsonConfig(filePath, (current) => upsertMcpServers(current, serverName, entry)); + const entry = { + type: 'stdio', + command: LOCAL_COMMAND[0], + args: LOCAL_COMMAND.slice(1) + }; + return updateJsonConfig(filePath, (current) => upsertMcpServers(current, 'btca-local', entry)); }; -const configureEditor = async (mode: McpMode, editor: McpEditor) => { +const configureEditor = async (editor: McpEditor) => { switch (editor) { case 'cursor': - return writeCursorConfig(mode); + return writeCursorConfig(); case 'opencode': - return writeOpenCodeConfig(mode); + return writeOpenCodeConfig(); case 'codex': - return writeCodexConfig(mode); + return writeCodexConfig(); case 'claude': - return writeClaudeConfig(mode); + return writeClaudeConfig(); } throw new Error(`Unsupported editor: ${editor}`); @@ -398,7 +355,8 @@ const runLocalServer = async (command: Command) => { mcpServer.tool( { name: 'ask', - description: 'Ask a question about local resources.', + description: + 'Ask a question about local resources, or any HTTPS GitHub repository URL passed in as a resource.', schema: askSchema }, async (args: AskInput) => { @@ -419,31 +377,24 @@ const runLocalServer = async (command: Command) => { transport.listen(); }; -const configureMcp = (mode: McpMode) => - new Command(mode) - .description(`Configure ${mode} MCP settings for your editor`) - .action(async () => { - const result = await Result.tryPromise(async () => { - const editor = await promptEditor(); - const filePath = await configureEditor(mode, editor); - const modeLabel = mode === 'local' ? 'Local' : 'Remote'; - console.log(`\n${modeLabel} MCP configured for ${editor} in: ${filePath}\n`); - - if (mode === 'remote') { - console.log('Replace the stubbed API key in your config.'); - console.log(`Get a key here: ${MCP_API_KEY_URL}\n`); - } - }); +const configureLocalMcp = new Command('local') + .description('Configure local MCP settings for your editor') + .action(async () => { + const result = await Result.tryPromise(async () => { + const editor = await promptEditor(); + const filePath = await configureEditor(editor); + console.log(`\nLocal MCP configured for ${editor} in: ${filePath}\n`); + }); - if (Result.isError(result)) { - if (result.error instanceof Error && result.error.message === 'Invalid selection') { - console.error('\nError: Invalid selection. Please try again.'); - } else { - console.error(formatError(result.error)); - } - process.exit(1); + if (Result.isError(result)) { + if (result.error instanceof Error && result.error.message === 'Invalid selection') { + console.error('\nError: Invalid selection. Please try again.'); + } else { + console.error(formatError(result.error)); } - }); + process.exit(1); + } + }); export const mcpCommand = new Command('mcp') .description('Run the local MCP server or configure editor MCP settings') @@ -454,5 +405,4 @@ export const mcpCommand = new Command('mcp') process.exit(1); } }) - .addCommand(configureMcp('local')) - .addCommand(configureMcp('remote')); + .addCommand(configureLocalMcp); diff --git a/apps/cli/src/commands/remote.ts b/apps/cli/src/commands/remote.ts deleted file mode 100644 index 445c53d2..00000000 --- a/apps/cli/src/commands/remote.ts +++ /dev/null @@ -1,977 +0,0 @@ -import { Result } from 'better-result'; -import { Command } from 'commander'; -import * as readline from 'readline'; -import { - RemoteClient, - RemoteApiError, - type GitResource, - type RemoteConfig -} from '../client/remote.ts'; -import { loadAuth, saveAuth, deleteAuth, type RemoteAuth } from '../lib/auth.ts'; -import { dim, green, red, yellow, bold } from '../lib/utils/colors.ts'; - -// ───────────────────────────────────────────────────────────────────────────── -// Config Constants -// ───────────────────────────────────────────────────────────────────────────── - -const REMOTE_CONFIG_FILENAME = 'btca.remote.config.jsonc'; -const REMOTE_CONFIG_SCHEMA_URL = 'https://btca.dev/btca.remote.schema.json'; - -async function requireAuth(): Promise { - const auth = await loadAuth(); - if (!auth) { - console.error(red('Not authenticated with remote.')); - console.error(`Run ${bold('btca remote link')} to authenticate.`); - process.exit(1); - } - return new RemoteClient({ apiKey: auth.apiKey }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Remote Config Helpers -// ───────────────────────────────────────────────────────────────────────────── - -function getConfigPath(cwd: string = process.cwd()): string { - return `${cwd}/${REMOTE_CONFIG_FILENAME}`; -} - -async function loadConfig(cwd: string = process.cwd()): Promise { - const configPath = getConfigPath(cwd); - const result = await Result.tryPromise(async () => { - const content = await Bun.file(configPath).text(); - const stripped = stripJsonComments(content); - return JSON.parse(stripped) as RemoteConfig; - }); - return Result.isOk(result) ? result.value : null; -} - -function stripJsonComments(content: string): string { - let result = ''; - let inString = false; - let inLineComment = false; - let inBlockComment = false; - let i = 0; - - while (i < content.length) { - const char = content[i]; - const next = content[i + 1]; - - if (inLineComment) { - if (char === '\n') { - inLineComment = false; - result += char; - } - i++; - continue; - } - - if (inBlockComment) { - if (char === '*' && next === '/') { - inBlockComment = false; - i += 2; - continue; - } - i++; - continue; - } - - if (inString) { - result += char; - if (char === '\\' && i + 1 < content.length) { - result += content[i + 1]; - i += 2; - continue; - } - if (char === '"') { - inString = false; - } - i++; - continue; - } - - if (char === '"') { - inString = true; - result += char; - i++; - continue; - } - - if (char === '/' && next === '/') { - inLineComment = true; - i += 2; - continue; - } - - if (char === '/' && next === '*') { - inBlockComment = true; - i += 2; - continue; - } - - result += char; - i++; - } - - return result.replace(/,(\s*[}\]])/g, '$1'); -} - -async function saveConfig(config: RemoteConfig, cwd: string = process.cwd()): Promise { - const configPath = getConfigPath(cwd); - const toSave = { - $schema: REMOTE_CONFIG_SCHEMA_URL, - ...config - }; - await Bun.write(configPath, JSON.stringify(toSave, null, '\t')); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Prompt Helpers -// ───────────────────────────────────────────────────────────────────────────── - -function createRl(): readline.Interface { - return readline.createInterface({ - input: process.stdin, - output: process.stdout - }); -} - -async function promptInput( - rl: readline.Interface, - question: string, - defaultValue?: string -): Promise { - return new Promise((resolve) => { - const defaultHint = defaultValue ? ` ${dim(`(${defaultValue})`)}` : ''; - rl.question(`${question}${defaultHint}: `, (answer) => { - const value = answer.trim(); - resolve(value || defaultValue || ''); - }); - }); -} - -async function promptConfirm(rl: readline.Interface, question: string): Promise { - return new Promise((resolve) => { - rl.question(`${question} ${dim('(y/n)')}: `, (answer) => { - resolve(answer.trim().toLowerCase() === 'y'); - }); - }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Error Formatting -// ───────────────────────────────────────────────────────────────────────────── - -function formatError(error: unknown): string { - if (error instanceof RemoteApiError) { - let output = `Error: ${error.message}`; - if (error.hint) { - output += `\n\nHint: ${error.hint}`; - } - return output; - } - return `Error: ${error instanceof Error ? error.message : String(error)}`; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Subcommands -// ───────────────────────────────────────────────────────────────────────────── - -/** - * btca remote link - Authenticate with remote instance - */ -const linkCommand = new Command('link') - .description('Authenticate with the btca cloud service') - .option('--key ', 'API key (if you have one already)') - .action(async (options: { key?: string }) => { - const result = await Result.tryPromise(async () => { - const existingAuth = await loadAuth(); - if (existingAuth) { - const rl = createRl(); - const overwrite = await promptConfirm( - rl, - 'You are already authenticated. Do you want to re-authenticate?' - ); - rl.close(); - if (!overwrite) { - console.log('Cancelled.'); - return; - } - } - - let apiKey = options.key; - - if (!apiKey) { - console.log('\n--- btca Remote Authentication ---\n'); - console.log('To authenticate, you need an API key from the btca web app.'); - console.log(`\n1. Go to ${bold('https://btca.dev/app/settings?tab=mcp')}`); - console.log('2. Create a new API key'); - console.log('3. Copy the key and paste it below\n'); - - const rl = createRl(); - apiKey = await promptInput(rl, 'API Key'); - rl.close(); - - if (!apiKey) { - console.error(red('API key is required.')); - process.exit(1); - } - } - - // Validate the API key - console.log('\nValidating API key...'); - const client = new RemoteClient({ apiKey }); - const validation = await client.validate(); - - if (!validation.valid) { - console.error(red(`\nAuthentication failed: ${validation.error}`)); - process.exit(1); - } - - // Save the auth - await saveAuth({ - apiKey, - linkedAt: Date.now() - }); - - console.log(green('\nAuthentication successful!')); - console.log(`\nYou can now use remote commands:`); - console.log(` ${dim('btca remote status')} - Check instance status`); - console.log(` ${dim('btca remote ask')} - Ask questions via cloud`); - console.log(` ${dim('btca remote sync')} - Sync local config with cloud`); - }); - - if (Result.isError(result)) { - console.error(formatError(result.error)); - process.exit(1); - } - }); - -/** - * btca remote unlink - Remove authentication - */ -const unlinkCommand = new Command('unlink') - .description('Remove authentication with the btca cloud service') - .action(async () => { - const result = await Result.tryPromise(async () => { - const auth = await loadAuth(); - if (!auth) { - console.log('Not currently authenticated.'); - return; - } - - await deleteAuth(); - console.log(green('Successfully unlinked from btca cloud.')); - }); - - if (Result.isError(result)) { - console.error(formatError(result.error)); - process.exit(1); - } - }); - -/** - * btca remote status - Show sandbox status - */ -const statusCommand = new Command('status') - .description('Show sandbox and project status') - .action(async () => { - const result = await Result.tryPromise(async () => { - const client = await requireAuth(); - const config = await loadConfig(); - - console.log('\n--- btca Remote Status ---\n'); - - const result = await client.getStatus(config?.project); - - if (!result.ok) { - console.error(red(`Error: ${result.error}`)); - process.exit(1); - } - - const { instance, project } = result; - - // Instance status - const stateColors: Record string> = { - running: green, - stopped: yellow, - error: red, - provisioning: yellow, - starting: yellow, - stopping: yellow - }; - const stateColor = stateColors[instance.state] ?? dim; - console.log(`Sandbox: ${stateColor(instance.state)}`); - - if (instance.subscriptionPlan) { - console.log(`Plan: ${instance.subscriptionPlan}`); - } - - if (instance.btcaVersion) { - console.log(`Version: ${instance.btcaVersion}`); - } - - // Project info - if (project) { - console.log(`\nProject: ${bold(project.name)}${project.isDefault ? ' (default)' : ''}`); - if (project.model) { - console.log(`Model: ${project.model}`); - } - } else if (config?.project) { - console.log(`\nLocal project: ${bold(config.project)} (not synced)`); - } - - // Local config info - if (config) { - console.log(`\nLocal resources: ${config.resources.length}`); - } else { - console.log(dim(`\nNo local remote config found (${REMOTE_CONFIG_FILENAME})`)); - } - - console.log(''); - }); - - if (Result.isError(result)) { - console.error(formatError(result.error)); - process.exit(1); - } - }); - -/** - * btca remote wake - Pre-warm the sandbox - */ -const wakeCommand = new Command('wake') - .description('Pre-warm the cloud sandbox') - .action(async () => { - const result = await Result.tryPromise(async () => { - const client = await requireAuth(); - - console.log('Waking sandbox...'); - const result = await client.wake(); - - if (!result.ok) { - console.error(red(`Error: ${result.error}`)); - process.exit(1); - } - - console.log(green('Sandbox is ready!')); - }); - - if (Result.isError(result)) { - console.error(formatError(result.error)); - process.exit(1); - } - }); - -interface GitHubUrlParts { - owner: string; - repo: string; -} - -function parseGitHubUrl(url: string): GitHubUrlParts | null { - const patterns = [ - /^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(\.git)?$/, - /^github\.com\/([^/]+)\/([^/]+?)(\.git)?$/ - ]; - - for (const pattern of patterns) { - const match = url.match(pattern); - if (match) { - return { owner: match[1]!, repo: match[2]! }; - } - } - - return null; -} - -function normalizeGitHubUrl(url: string): string { - const parts = parseGitHubUrl(url); - if (!parts) return url; - return `https://github.com/${parts.owner}/${parts.repo}`; -} - -async function promptRepeated(rl: readline.Interface, itemName: string): Promise { - const items: string[] = []; - - console.log(`\nEnter ${itemName} one at a time. Press Enter with empty input when done.`); - - while (true) { - const value = await promptInput(rl, ` ${itemName} ${items.length + 1}`); - if (!value) break; - items.push(value); - } - - return items; -} - -async function addRemoteResourceWizard(url: string): Promise { - const urlParts = parseGitHubUrl(url); - if (!urlParts) { - console.error(red('Invalid GitHub URL.')); - console.error('Expected format: https://github.com/owner/repo'); - process.exit(1); - } - - const normalizedUrl = normalizeGitHubUrl(url); - - console.log('\n--- Add Remote Resource ---\n'); - console.log(`Repository: ${normalizedUrl}`); - - const rl = createRl(); - - const result = await Result.tryPromise(async () => { - const finalUrl = await promptInput(rl, 'URL', normalizedUrl); - - const defaultName = urlParts.repo; - const name = await promptInput(rl, 'Name', defaultName); - - const branch = await promptInput(rl, 'Branch', 'main'); - - const wantSearchPaths = await promptConfirm( - rl, - 'Do you want to add search paths (subdirectories to focus on)?' - ); - const searchPaths = wantSearchPaths ? await promptRepeated(rl, 'Search path') : []; - - const notes = await promptInput(rl, 'Notes (optional)'); - - rl.close(); - - console.log('\n--- Summary ---\n'); - console.log(` Type: git`); - console.log(` Name: ${name}`); - console.log(` URL: ${finalUrl}`); - console.log(` Branch: ${branch}`); - if (searchPaths.length > 0) { - console.log(` Search: ${searchPaths.join(', ')}`); - } - if (notes) { - console.log(` Notes: ${notes}`); - } - console.log(''); - - const confirmRl = createRl(); - const confirmed = await promptConfirm(confirmRl, 'Add this resource?'); - confirmRl.close(); - - if (!confirmed) { - console.log('\nCancelled.'); - process.exit(0); - } - - const client = await requireAuth(); - let config = await loadConfig(); - - if (!config) { - const projectRl = createRl(); - const projectName = await promptInput(projectRl, 'Project name for remote config', 'default'); - projectRl.close(); - - if (!projectName) { - console.error(red('Project name is required.')); - process.exit(1); - } - - config = { - project: projectName, - model: 'claude-haiku', - resources: [] - }; - } - - const resource: GitResource = { - type: 'git', - name, - url: finalUrl, - branch, - ...(searchPaths.length === 1 && { searchPath: searchPaths[0] }), - ...(searchPaths.length > 1 && { searchPaths }), - ...(notes && { specialNotes: notes }) - }; - - if (config.resources.some((r) => r.name === name)) { - console.error(red(`Resource "${name}" already exists in config.`)); - process.exit(1); - } - - config.resources.push(resource); - await saveConfig(config); - - console.log(`\nAdded "${name}" to local config.`); - - console.log('Syncing to cloud...'); - const syncResult = await client.addResource(resource, config.project); - - if (!syncResult.ok) { - console.error(yellow(`Warning: Failed to sync to cloud: ${syncResult.error}`)); - console.error('The resource has been added to your local config.'); - console.error(`Run ${bold('btca remote sync')} to try again.`); - } else { - console.log(green(`Successfully added and synced "${name}"!`)); - } - - console.log('\nYou can now use this resource:'); - console.log(` ${dim(`btca remote ask -q "your question" -r ${name}`)}`); - }); - - rl.close(); - - if (Result.isError(result)) { - throw result.error; - } -} - -/** - * btca remote add - Add resource to remote config and sync - */ -const addCommand = new Command('add') - .description('Add a resource to remote config and sync to cloud') - .argument('[url]', 'GitHub repository URL') - .option('-n, --name ', 'Resource name') - .option('-b, --branch ', 'Git branch (default: main)') - .option('-s, --search-path ', 'Search paths within repo') - .option('--notes ', 'Special notes for the agent') - .action( - async ( - url: string | undefined, - options: { - name?: string; - branch?: string; - searchPath?: string[]; - notes?: string; - } - ) => { - const result = await Result.tryPromise(async () => { - if (!url) { - const rl = createRl(); - const inputUrl = await promptInput(rl, 'GitHub URL'); - rl.close(); - - if (!inputUrl) { - console.error(red('URL is required.')); - process.exit(1); - } - - await addRemoteResourceWizard(inputUrl); - return; - } - - const urlParts = parseGitHubUrl(url); - if (!urlParts) { - console.error(red('Invalid GitHub URL.')); - console.error('Expected format: https://github.com/owner/repo'); - process.exit(1); - } - - if (options.name) { - const normalizedUrl = normalizeGitHubUrl(url); - const client = await requireAuth(); - let config = await loadConfig(); - - if (!config) { - const rl = createRl(); - const projectName = await promptInput(rl, 'Project name for remote config', 'default'); - rl.close(); - - if (!projectName) { - console.error(red('Project name is required.')); - process.exit(1); - } - - config = { - project: projectName, - model: 'claude-sonnet', - resources: [] - }; - } - - const resource: GitResource = { - type: 'git', - name: options.name, - url: normalizedUrl, - branch: options.branch ?? 'main', - ...(options.searchPath?.length === 1 && { searchPath: options.searchPath[0] }), - ...(options.searchPath && - options.searchPath.length > 1 && { searchPaths: options.searchPath }), - ...(options.notes && { specialNotes: options.notes }) - }; - - if (config.resources.some((r) => r.name === options.name)) { - console.error(red(`Resource "${options.name}" already exists in config.`)); - process.exit(1); - } - - config.resources.push(resource); - await saveConfig(config); - - console.log(`Added "${options.name}" to local config.`); - - console.log('Syncing to cloud...'); - const syncResult = await client.addResource(resource, config.project); - - if (!syncResult.ok) { - console.error(yellow(`Warning: Failed to sync to cloud: ${syncResult.error}`)); - console.error('The resource has been added to your local config.'); - console.error(`Run ${bold('btca remote sync')} to try again.`); - } else { - console.log(green(`Successfully added and synced "${options.name}"!`)); - } - return; - } - - await addRemoteResourceWizard(url); - }); - - if (Result.isError(result)) { - console.error(formatError(result.error)); - process.exit(1); - } - } - ); - -/** - * btca remote sync - Sync local config with cloud - */ -const syncCommand = new Command('sync') - .description('Sync local remote config with cloud') - .option('--force', 'Force push local config, overwriting cloud on conflicts') - .action(async (options: { force?: boolean }) => { - const result = await Result.tryPromise(async () => { - const client = await requireAuth(); - const config = await loadConfig(); - - if (!config) { - console.error(red(`No remote config found (${REMOTE_CONFIG_FILENAME}).`)); - console.error('Create a remote config first or use `btca remote add` to start.'); - process.exit(1); - } - - console.log(`Syncing project "${config.project}"...`); - - const result = await client.sync(config, options.force); - - if (!result.ok) { - if (result.conflicts && result.conflicts.length > 0) { - console.error(red('\nConflicts detected:')); - for (const conflict of result.conflicts) { - console.error(`\n ${bold(conflict.name)}:`); - console.error(` Local: ${conflict.local.url} @ ${conflict.local.branch}`); - console.error(` Remote: ${conflict.remote.url} @ ${conflict.remote.branch}`); - } - console.error( - `\nUse ${bold('--force')} to overwrite cloud config, or update local config to match.` - ); - } else if (result.errors) { - for (const err of result.errors) { - console.error(red(`Error: ${err}`)); - } - } - process.exit(1); - } - - if (result.synced.length > 0) { - console.log(green('\nSynced resources:')); - for (const name of result.synced) { - console.log(` - ${name}`); - } - } else { - console.log(green('\nAlready in sync!')); - } - }); - - if (Result.isError(result)) { - console.error(formatError(result.error)); - process.exit(1); - } - }); - -/** - * btca remote ask - Ask a question via cloud - */ -const askCommand = new Command('ask') - .description('Ask a question via the cloud sandbox') - .requiredOption('-q, --question ', 'Question to ask') - .option('-r, --resource ', 'Resources to query') - .action(async (options: { question: string; resource?: string[] }) => { - const result = await Result.tryPromise(async () => { - const client = await requireAuth(); - const config = await loadConfig(); - - // Get available resources - const resourcesResult = await client.listResources(config?.project); - if (!resourcesResult.ok) { - console.error(red(`Error: ${resourcesResult.error}`)); - process.exit(1); - } - - const available = resourcesResult.resources; - if (available.length === 0) { - console.error(red('No resources available.')); - console.error('Add resources first with `btca remote add`.'); - process.exit(1); - } - - // Determine which resources to use - let resources: string[]; - if (options.resource && options.resource.length > 0) { - // Validate requested resources - const invalid = options.resource.filter( - (r) => !available.some((a) => a.name.toLowerCase() === r.toLowerCase()) - ); - if (invalid.length > 0) { - console.error(red(`Invalid resources: ${invalid.join(', ')}`)); - console.error(`Available: ${available.map((a) => a.name).join(', ')}`); - process.exit(1); - } - resources = options.resource; - } else { - // Use all available resources - resources = available.map((a) => a.name); - } - - console.log('Asking...\n'); - - const result = await client.ask(options.question, resources, config?.project); - - if (!result.ok) { - console.error(red(`Error: ${result.error}`)); - process.exit(1); - } - - console.log(result.text); - console.log(''); - }); - - if (Result.isError(result)) { - console.error(formatError(result.error)); - process.exit(1); - } - }); - -/** - * btca remote grab - Output thread transcript - */ -const grabCommand = new Command('grab') - .description('Output the full transcript of a thread') - .argument('', 'Thread ID to fetch') - .option('--json', 'Output as JSON') - .option('--markdown', 'Output as markdown (default)') - .action(async (threadId: string, options: { json?: boolean; markdown?: boolean }) => { - const result = await Result.tryPromise(async () => { - const client = await requireAuth(); - - const result = await client.getThread(threadId); - - if (!result.ok) { - console.error(red(`Error: ${result.error}`)); - process.exit(1); - } - - const { thread, messages } = result; - - if (options.json) { - console.log(JSON.stringify({ thread, messages }, null, 2)); - return; - } - - // Markdown output (default) - console.log(`# ${thread.title ?? 'Untitled Thread'}\n`); - console.log(`Thread ID: ${thread._id}`); - console.log(`Created: ${new Date(thread.createdAt).toISOString()}\n`); - console.log('---\n'); - - for (const msg of messages) { - const roleLabel = - msg.role === 'user' - ? '**User**' - : msg.role === 'assistant' - ? '**Assistant**' - : '**System**'; - console.log(`${roleLabel}:\n`); - console.log(msg.content); - console.log('\n---\n'); - } - }); - - if (Result.isError(result)) { - console.error(formatError(result.error)); - process.exit(1); - } - }); - -/** - * btca remote init - Initialize a remote config file - */ -const initCommand = new Command('init') - .description('Initialize a remote config file in the current directory') - .option('-p, --project ', 'Project name') - .action(async (options: { project?: string }) => { - const result = await Result.tryPromise(async () => { - const existingConfig = await loadConfig(); - if (existingConfig) { - console.error(red(`Remote config already exists (${REMOTE_CONFIG_FILENAME}).`)); - process.exit(1); - } - - let projectName = options.project; - - if (!projectName) { - const rl = createRl(); - projectName = await promptInput(rl, 'Project name', 'default'); - rl.close(); - } - - if (!projectName) { - console.error(red('Project name is required.')); - process.exit(1); - } - - const config: RemoteConfig = { - project: projectName, - model: 'claude-haiku', - resources: [] - }; - - await saveConfig(config); - - console.log(green(`Created ${REMOTE_CONFIG_FILENAME}`)); - console.log(`\nNext steps:`); - console.log(` 1. ${dim('btca remote link')} - Authenticate (if not already)`); - console.log(` 2. ${dim('btca remote add ')} - Add resources`); - console.log(` 3. ${dim('btca remote sync')} - Sync to cloud`); - }); - - if (Result.isError(result)) { - console.error(formatError(result.error)); - process.exit(1); - } - }); - -// ───────────────────────────────────────────────────────────────────────────── -// MCP Config Templates -// ───────────────────────────────────────────────────────────────────────────── - -type McpAgent = 'opencode' | 'claude'; - -const MCP_API_KEY_URL = 'https://btca.dev/app/settings?tab=mcp'; - -function getMcpConfig(agent: McpAgent, apiKey: string): object | string { - const mcpUrl = 'https://btca.dev/api/mcp'; - - switch (agent) { - case 'opencode': - return { - $schema: 'https://opencode.ai/config.json', - mcp: { - 'better-context': { - type: 'remote', - url: mcpUrl, - enabled: true, - headers: { - Authorization: `Bearer ${apiKey}` - } - } - } - }; - case 'claude': - // Return shell command string for Claude Code - return `claude mcp add --transport http better-context ${mcpUrl} \\ - --header "Authorization: Bearer ${apiKey}"`; - } -} - -function getMcpInstructions(agent: McpAgent): string { - switch (agent) { - case 'opencode': - return `Add this to your ${bold('opencode.json')} file:`; - case 'claude': - return `Run this command to add the MCP server:`; - } -} - -/** - * btca remote mcp - Output MCP configuration for various agents - */ -const mcpCommand = new Command('mcp') - .description('Output MCP configuration for your AI agent') - .argument('[agent]', 'Agent type: opencode or claude') - .action(async (agent?: string) => { - const result = await Result.tryPromise(async () => { - const auth = await loadAuth(); - if (!auth) { - console.error(red('Not authenticated with remote.')); - console.error(`\nGet your API key from: ${bold(MCP_API_KEY_URL)}`); - console.error(`Then run ${bold('btca remote link')} to authenticate.`); - process.exit(1); - } - - let selectedAgent: McpAgent; - - if (agent) { - const normalized = agent.toLowerCase(); - if (normalized !== 'opencode' && normalized !== 'claude') { - console.error(red(`Invalid agent: ${agent}`)); - console.error('Valid options: opencode, claude'); - process.exit(1); - } - selectedAgent = normalized as McpAgent; - } else { - // Prompt user to select agent - console.log('\nSelect your AI agent:\n'); - console.log(' 1) OpenCode'); - console.log(' 2) Claude Code'); - console.log(''); - - const rl = createRl(); - const answer = await new Promise((resolve) => { - rl.question('Enter number: ', (ans) => { - rl.close(); - resolve(ans.trim()); - }); - }); - - const num = parseInt(answer, 10); - if (num === 1) { - selectedAgent = 'opencode'; - } else if (num === 2) { - selectedAgent = 'claude'; - } else { - console.error(red('Invalid selection.')); - process.exit(1); - } - } - - const config = getMcpConfig(selectedAgent, auth.apiKey); - const instructions = getMcpInstructions(selectedAgent); - - console.log(`\n${instructions}\n`); - if (typeof config === 'string') { - // Claude Code outputs a shell command - console.log(config); - } else { - console.log(JSON.stringify(config, null, 2)); - } - console.log(''); - }); - - if (Result.isError(result)) { - console.error(formatError(result.error)); - process.exit(1); - } - }); - -// ───────────────────────────────────────────────────────────────────────────── -// Main Remote Command -// ───────────────────────────────────────────────────────────────────────────── - -export const remoteCommand = new Command('remote') - .description('Manage btca cloud service (remote mode)') - .addCommand(linkCommand) - .addCommand(unlinkCommand) - .addCommand(statusCommand) - .addCommand(wakeCommand) - .addCommand(addCommand) - .addCommand(syncCommand) - .addCommand(askCommand) - .addCommand(grabCommand) - .addCommand(initCommand) - .addCommand(mcpCommand); diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts new file mode 100644 index 00000000..ea0c75b2 --- /dev/null +++ b/apps/cli/src/commands/status.ts @@ -0,0 +1,288 @@ +import { Result } from 'better-result'; +import { Command } from 'commander'; +import os from 'node:os'; +import path from 'node:path'; +import { ensureServer } from '../server/manager.ts'; +import { createClient, getConfig, getProviders, BtcaError } from '../client/index.ts'; +import packageJson from '../../package.json'; + +declare const __VERSION__: string; +const VERSION = typeof __VERSION__ !== 'undefined' ? __VERSION__ : (packageJson.version ?? '0.0.0'); + +const GLOBAL_CONFIG_PATH = path.join(os.homedir(), '.config', 'btca', 'btca.config.jsonc'); +const PROJECT_CONFIG_FILENAME = 'btca.config.jsonc'; +const NPM_REGISTRY_URL = 'https://registry.npmjs.org/btca'; + +type NpmInfo = { + 'dist-tags'?: { + latest?: string; + }; + version?: string; +}; + +type StoredResource = { + name?: unknown; +}; + +type StoredConfig = { + model?: unknown; + provider?: unknown; + resources?: unknown; +}; + +function formatError(error: unknown): string { + if (error instanceof BtcaError) { + let output = `Error: ${error.message}`; + if (error.hint) { + output += `\n\nHint: ${error.hint}`; + } + return output; + } + return `Error: ${error instanceof Error ? error.message : String(error)}`; +} + +const stripJsonc = (content: string): string => { + let out = ''; + let i = 0; + let inString = false; + let quote: '"' | "'" | null = null; + let escaped = false; + + while (i < content.length) { + const ch = content[i] ?? ''; + const next = content[i + 1] ?? ''; + + if (inString) { + out += ch; + if (escaped) escaped = false; + else if (ch === '\\') escaped = true; + else if (quote && ch === quote) { + inString = false; + quote = null; + } + i += 1; + continue; + } + + if (ch === '/' && next === '/') { + i += 2; + while (i < content.length && content[i] !== '\n') i += 1; + continue; + } + + if (ch === '/' && next === '*') { + i += 2; + while (i < content.length) { + if (content[i] === '*' && content[i + 1] === '/') { + i += 2; + break; + } + i += 1; + } + continue; + } + + if (ch === '"' || ch === "'") { + inString = true; + quote = ch; + out += ch; + i += 1; + continue; + } + + out += ch; + i += 1; + } + + let normalized = ''; + inString = false; + quote = null; + escaped = false; + i = 0; + + while (i < out.length) { + const ch = out[i] ?? ''; + + if (inString) { + normalized += ch; + if (escaped) escaped = false; + else if (ch === '\\') escaped = true; + else if (quote && ch === quote) { + inString = false; + quote = null; + } + i += 1; + continue; + } + + if (ch === '"' || ch === "'") { + inString = true; + quote = ch; + normalized += ch; + i += 1; + continue; + } + + if (ch === ',') { + let j = i + 1; + while (j < out.length && /\s/.test(out[j] ?? '')) j += 1; + const nextNonWs = out[j] ?? ''; + if (nextNonWs === ']' || nextNonWs === '}') { + i += 1; + continue; + } + } + + normalized += ch; + i += 1; + } + + return normalized.trim(); +}; + +const readConfigFromPath = async (configPath: string): Promise => { + const configFile = Bun.file(configPath); + if (!(await configFile.exists())) { + return null; + } + + const content = await configFile.text(); + const parsed: unknown = JSON.parse(stripJsonc(content)); + return parsed as StoredConfig; +}; + +const listResourceNames = (config: StoredConfig | null): string[] => { + if (!config || !Array.isArray(config.resources)) { + return []; + } + + return config.resources + .filter((resource) => { + const candidate = resource as StoredResource; + return candidate && typeof candidate === 'object' && typeof candidate.name === 'string'; + }) + .map((resource) => (resource as StoredResource).name as string); +}; + +const getConfigOrigin = ( + value: 'provider' | 'model', + projectConfig: StoredConfig | null, + globalConfig: StoredConfig | null +): string => { + const hasInProject = projectConfig && typeof projectConfig[value] === 'string'; + if (hasInProject) return 'project'; + const hasInGlobal = globalConfig && typeof globalConfig[value] === 'string'; + if (hasInGlobal) return 'global'; + return 'default'; +}; + +const compareVersions = (left: string, right: string): number => { + const toParts = (value: string) => + (value.trim().replace(/^v/, '').split(/[-+]/, 1)[0] ?? '') + .split('.') + .map((part) => Number.parseInt(part, 10)); + + const l = toParts(left); + const r = toParts(right); + const max = Math.max(l.length, r.length); + + for (let i = 0; i < max; i += 1) { + const lv = Number.isNaN(l[i] ?? NaN) ? 0 : (l[i] ?? 0); + const rv = Number.isNaN(r[i] ?? NaN) ? 0 : (r[i] ?? 0); + if (lv > rv) return 1; + if (lv < rv) return -1; + } + + return 0; +}; + +const getLatestVersion = async (): Promise => { + try { + const response = await fetch(NPM_REGISTRY_URL); + if (!response.ok) { + return null; + } + + const info = (await response.json()) as NpmInfo; + return info['dist-tags']?.latest ?? info.version ?? null; + } catch { + return null; + } +}; + +const printResourceList = (label: string, resources: string[] | null) => { + if (resources === null) { + console.log(`${label}: (not found)`); + return; + } + + console.log(`${label}:`); + if (resources.length === 0) { + console.log(' (none)'); + return; + } + + for (const resource of resources) { + console.log(` ${resource}`); + } +}; + +export const statusCommand = new Command('status') + .description('Show selected provider/model and config status') + .action(async () => { + const result = await Result.tryPromise(async () => { + const server = await ensureServer({ quiet: true }); + const client = createClient(server.url); + const projectPath = path.resolve(process.cwd(), PROJECT_CONFIG_FILENAME); + + try { + const [config, providers, globalConfig, projectConfig] = await Promise.all([ + getConfig(client), + getProviders(client), + readConfigFromPath(GLOBAL_CONFIG_PATH), + readConfigFromPath(projectPath) + ]); + + const connected = Array.isArray(providers.connected) ? providers.connected : []; + const isAuthenticated = connected.includes(config.provider); + const latestVersion = await getLatestVersion(); + const hasUpdate = latestVersion && compareVersions(VERSION, latestVersion) < 0; + + console.log('\n--- btca status ---\n'); + const modelSource = getConfigOrigin('model', projectConfig, globalConfig); + const providerSource = getConfigOrigin('provider', projectConfig, globalConfig); + console.log(`Selected model: ${config.model} (${modelSource})`); + console.log(`Selected provider: ${config.provider} (${providerSource})`); + console.log(`Selected provider authed: ${isAuthenticated ? 'yes' : 'no'}`); + console.log(''); + + printResourceList( + 'Global resources', + globalConfig ? listResourceNames(globalConfig) : null + ); + printResourceList( + 'Project resources', + projectConfig ? listResourceNames(projectConfig) : null + ); + + console.log(`\nbtca version: ${VERSION}`); + if (latestVersion) { + console.log(`Latest version: ${latestVersion}`); + if (hasUpdate) { + console.log('Update available: run "bun add -g btca@latest"'); + } else { + console.log('btca is up to date'); + } + } else { + console.log('Latest version: unavailable'); + } + } finally { + server.stop(); + } + }); + + if (Result.isError(result)) { + console.error(formatError(result.error)); + process.exit(1); + } + }); diff --git a/apps/cli/src/commands/wipe.ts b/apps/cli/src/commands/wipe.ts new file mode 100644 index 00000000..fcb892c2 --- /dev/null +++ b/apps/cli/src/commands/wipe.ts @@ -0,0 +1,145 @@ +import { Result } from 'better-result'; +import { Command } from 'commander'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import * as readline from 'readline'; +import { bold, dim, red, yellow } from '../lib/utils/colors.ts'; + +const home = path.resolve(os.homedir()); +const configFilenames = ['btca.config.jsonc']; +const globalConfigDir = path.join(home, '.config', 'btca'); + +const createRl = () => + readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + +const promptInput = (rl: readline.Interface, question: string) => + new Promise((resolve) => { + rl.question(question, (answer) => resolve(answer.trim())); + }); + +const listTargets = () => { + const cwd = process.cwd(); + const targets = new Map(); + + for (const name of configFilenames) { + targets.set(path.resolve(cwd, name), 'project config'); + targets.set(path.resolve(globalConfigDir, name), 'global config'); + } + + return [...targets.entries()].map(([target, source]) => ({ target, source })); +}; + +const removeTarget = async (target: string) => { + const exists = await Bun.file(target).exists(); + if (!exists) return { kind: 'missing' as const, target }; + + try { + await fs.rm(target, { force: true }); + return { kind: 'removed' as const, target }; + } catch (error) { + return { + kind: 'failed' as const, + target, + error: error instanceof Error ? error.message : String(error) + }; + } +}; + +const confirmWipe = async (targets: { target: string; source: string }[]) => { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + console.error('Refusing to run wipe in non-interactive mode without --yes.'); + process.exit(1); + } + + console.log(red('\nWARNING: this will permanently delete BTCA config files.')); + console.log(yellow('Only current-directory and global BTCA config files will be removed.')); + console.log('\nTargets:'); + for (const { target, source } of targets) { + console.log(`- ${target} ${dim(`(${source})`)}`); + } + console.log(''); + + const rl = createRl(); + try { + const answer = await promptInput(rl, `Type ${bold('WIPE')} to continue: `); + if (answer !== 'WIPE') { + console.log('Cancelled.'); + process.exit(0); + } + } finally { + rl.close(); + } +}; + +const runWipe = async () => { + const targets = listTargets(); + const removed: string[] = []; + const missing: string[] = []; + const failed: string[] = []; + + for (const { target, source } of targets) { + const result = await removeTarget(target); + if (result.kind === 'removed') { + removed.push(`${result.target} (${source})`); + continue; + } + if (result.kind === 'missing') { + missing.push(`${result.target} (${source})`); + continue; + } + failed.push(`${result.target} (${source}) -> ${result.error}`); + } + + return { targets, removed, missing, failed }; +}; + +const printReport = (result: Awaited>) => { + console.log('\nBTCA wipe complete.'); + console.log(`Directory: ${process.cwd()}`); + console.log(`Targets: ${result.targets.length}`); + console.log(`Removed: ${result.removed.length}`); + console.log(`Missing: ${result.missing.length}`); + console.log(`Failed: ${result.failed.length}`); + + if (result.removed.length) { + console.log('\nRemoved paths:'); + for (const line of result.removed) console.log(`- ${line}`); + } + + if (result.missing.length) { + console.log('\nNot found:'); + for (const line of result.missing) console.log(`- ${line}`); + } + + if (result.failed.length) { + console.log('\nFailed removals:'); + for (const line of result.failed) console.log(`- ${line}`); + } +}; + +export const wipeCommand = new Command('wipe') + .description('Delete BTCA config files in the current directory and global config directory') + .option('-y, --yes', 'Skip confirmation prompt') + .action(async (options: { yes?: boolean }) => { + const result = await Result.tryPromise(async () => { + const targets = listTargets(); + if (!options.yes) { + await confirmWipe(targets); + } + + const wipeResult = await runWipe(); + printReport(wipeResult); + if (wipeResult.failed.length > 0) process.exitCode = 1; + }); + + if (Result.isError(result)) { + console.error( + `Error: ${result.error instanceof Error ? result.error.message : String(result.error)}` + ); + process.exit(1); + } + }); diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 5528c5dd..9fb5d05b 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -6,15 +6,16 @@ import { clearCommand } from './commands/clear.ts'; import { connectCommand } from './commands/connect.ts'; import { disconnectCommand } from './commands/disconnect.ts'; import { initCommand } from './commands/init.ts'; +import { statusCommand } from './commands/status.ts'; import { mcpCommand } from './commands/mcp.ts'; import { removeCommand } from './commands/remove.ts'; import { resourcesCommand } from './commands/resources.ts'; -import { remoteCommand } from './commands/remote.ts'; import { serveCommand } from './commands/serve.ts'; import { skillCommand } from './commands/skill.ts'; import { telemetryCommand } from './commands/telemetry.ts'; import { launchTui } from './commands/tui.ts'; import { launchRepl } from './commands/repl.ts'; +import { wipeCommand } from './commands/wipe.ts'; import { setTelemetryContext } from './lib/telemetry.ts'; import packageJson from '../package.json'; @@ -52,15 +53,15 @@ program.addCommand(askCommand); program.addCommand(connectCommand); program.addCommand(disconnectCommand); program.addCommand(initCommand); +program.addCommand(statusCommand); program.addCommand(skillCommand); // Utility commands program.addCommand(clearCommand); +program.addCommand(wipeCommand); program.addCommand(mcpCommand); program.addCommand(serveCommand); -// Remote mode commands -program.addCommand(remoteCommand); program.addCommand(telemetryCommand); // Default action (no subcommand) → launch TUI or REPL diff --git a/apps/cli/src/lib/auth.ts b/apps/cli/src/lib/auth.ts deleted file mode 100644 index b0b5679a..00000000 --- a/apps/cli/src/lib/auth.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Auth helpers for btca CLI - * Shared authentication functions for remote commands - */ - -import { Result } from 'better-result'; - -const GLOBAL_CONFIG_DIR = '~/.config/btca'; -const REMOTE_AUTH_FILENAME = 'remote-auth.json'; - -const expandHome = (filePath: string): string => { - const home = process.env.HOME ?? process.env.USERPROFILE ?? ''; - if (filePath.startsWith('~/')) return home + filePath.slice(1); - return filePath; -}; - -export interface RemoteAuth { - apiKey: string; - linkedAt: number; -} - -export async function getAuthPath(): Promise { - return `${expandHome(GLOBAL_CONFIG_DIR)}/${REMOTE_AUTH_FILENAME}`; -} - -export async function loadAuth(): Promise { - const authPath = await getAuthPath(); - const result = await Result.tryPromise(async () => { - const content = await Bun.file(authPath).text(); - return JSON.parse(content) as RemoteAuth; - }); - return Result.isOk(result) ? result.value : null; -} - -export async function saveAuth(auth: RemoteAuth): Promise { - const authPath = await getAuthPath(); - const configDir = authPath.slice(0, authPath.lastIndexOf('/')); - - await Bun.write(`${configDir}/.keep`, ''); - await Bun.write(authPath, JSON.stringify(auth, null, 2)); -} - -export async function deleteAuth(): Promise { - const authPath = await getAuthPath(); - const result = await Result.tryPromise(async () => { - const fs = await import('node:fs/promises'); - await fs.unlink(authPath); - }); - if (Result.isError(result)) return; -} diff --git a/apps/cli/src/tui/components/connect-wizard.tsx b/apps/cli/src/tui/components/connect-wizard.tsx index 31db5c9b..5f0ccda5 100644 --- a/apps/cli/src/tui/components/connect-wizard.tsx +++ b/apps/cli/src/tui/components/connect-wizard.tsx @@ -565,13 +565,18 @@ export const ConnectWizard = (props: ConnectWizardProps) => { {step === 'provider' ? providerOptions.map((provider, i) => { const isSelected = i === selectedProviderIndex; + const providerColor = provider.connected + ? colors.success + : isSelected + ? colors.accent + : colors.text; return ( ' : ' '} /> - + ); }) diff --git a/apps/cli/src/tui/components/input-section.tsx b/apps/cli/src/tui/components/input-section.tsx index 3cd003d9..beb3a5af 100644 --- a/apps/cli/src/tui/components/input-section.tsx +++ b/apps/cli/src/tui/components/input-section.tsx @@ -117,7 +117,11 @@ export const InputSection = () => { .join('') .trim(); if (!inputText) return; - if (cursorIsCurrentlyIn === 'command' || cursorIsCurrentlyIn === 'mention') return; + if ( + cursorIsCurrentlyIn === 'command' || + (cursorIsCurrentlyIn === 'mention' && !isCurrentMentionResolved) + ) + return; if (messages.isStreaming) return; const parsed = parseAllMentions(inputText); @@ -214,7 +218,11 @@ export const InputSection = () => { } if (key.name === 'return' && !isAnyWizardOpen && !messages.isStreaming) { - if (cursorIsCurrentlyIn === 'text' || cursorIsCurrentlyIn === 'pasted') { + if ( + cursorIsCurrentlyIn === 'text' || + cursorIsCurrentlyIn === 'pasted' || + (cursorIsCurrentlyIn === 'mention' && isCurrentMentionResolved) + ) { void handleSubmit(); } } diff --git a/apps/cli/src/tui/components/messages.tsx b/apps/cli/src/tui/components/messages.tsx index d42c2111..7ef10524 100644 --- a/apps/cli/src/tui/components/messages.tsx +++ b/apps/cli/src/tui/components/messages.tsx @@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react'; import { MarkdownText } from './markdown-text.tsx'; import { useMessagesContext } from '../context/messages-context.tsx'; +import { useToast } from '../context/toast-context.tsx'; +import { openBrowser } from '../lib/open-browser.ts'; import { colors, getColor } from '../theme.ts'; import type { AssistantContent, BtcaChunk } from '../types.ts'; @@ -74,7 +76,81 @@ const TextChunk = (props: { chunk: Extract; isStreaming: boolean; }) => { - return ; + return ; +}; + +type ParsedSource = { label: string; url: string }; + +const parseSources = (content: string): { body: string; sources: ParsedSource[] } => { + const lines = content.split('\n'); + const headingIndex = lines.findIndex((line) => line.trim().toLowerCase() === 'sources'); + if (headingIndex < 0) return { body: content, sources: [] }; + + const body = lines.slice(0, headingIndex).join('\n').trimEnd(); + const sourceLines = lines.slice(headingIndex + 1); + const sources: ParsedSource[] = []; + + for (const line of sourceLines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('-')) continue; + const markdownMatch = trimmed.match(/^-\s*\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)\s*$/i); + if (markdownMatch) { + sources.push({ label: markdownMatch[1] ?? '', url: markdownMatch[2] ?? '' }); + continue; + } + + const labelUrlMatch = trimmed.match(/^-\s*(.+?)\s+\((https?:\/\/[^\s)]+)\)\s*$/i); + if (labelUrlMatch) { + sources.push({ label: labelUrlMatch[1] ?? '', url: labelUrlMatch[2] ?? '' }); + continue; + } + + const rawUrlMatch = trimmed.match(/^-\s*(https?:\/\/[^\s)]+)\s*$/i); + if (rawUrlMatch) { + const url = rawUrlMatch[1] ?? ''; + sources.push({ label: url, url }); + } + } + + return { body, sources }; +}; + +const SourceLinks = (props: { sources: ParsedSource[] }) => { + const toast = useToast(); + if (props.sources.length === 0) return null; + + return ( + + Sources + {props.sources.map((source, index) => ( + { + void openBrowser(source.url) + .then(() => toast.show(`Opened: ${source.url}`)) + .catch(() => toast.show('Failed to open URL')); + }} + > + {`- ${source.label} (${source.url})`} + + ))} + + ); +}; + +const AssistantText = (props: { content: string; streaming: boolean }) => { + const parsed = useMemo(() => parseSources(props.content), [props.content]); + if (parsed.sources.length === 0) { + return ; + } + + return ( + + {parsed.body ? : null} + + + ); }; const ChunkRenderer = (props: { chunk: BtcaChunk; isStreaming: boolean }) => { @@ -187,14 +263,14 @@ const AssistantMessage = (props: { if (props.isCanceled) { return {props.content}; } - return ; + return ; } if (props.content.type === 'text') { if (props.isCanceled) { return {props.content.content}; } - return ; + return ; } if (props.content.type === 'chunks') { diff --git a/apps/cli/src/tui/components/resume-thread-modal.lib.test.ts b/apps/cli/src/tui/components/resume-thread-modal.lib.test.ts new file mode 100644 index 00000000..fabae1ab --- /dev/null +++ b/apps/cli/src/tui/components/resume-thread-modal.lib.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'bun:test'; + +import { getVisibleRangeStart, normalizeResumeThreadLabel } from './resume-thread-modal.lib.ts'; + +describe('resume-thread-modal helpers', () => { + test('normalizes title whitespace to keep rows single-line', () => { + expect(normalizeResumeThreadLabel(' hello\n\nworld\tthere ')).toBe('hello world there'); + expect(normalizeResumeThreadLabel('')).toBe('Untitled thread'); + expect(normalizeResumeThreadLabel()).toBe('Untitled thread'); + }); + + test('centers visible range around selection when possible', () => { + expect(getVisibleRangeStart({ selectedIndex: 5, maxVisibleItems: 5, totalItems: 20 })).toBe(3); + }); + + test('clamps visible range near start and end', () => { + expect(getVisibleRangeStart({ selectedIndex: 0, maxVisibleItems: 5, totalItems: 20 })).toBe(0); + expect(getVisibleRangeStart({ selectedIndex: 19, maxVisibleItems: 5, totalItems: 20 })).toBe( + 15 + ); + }); + + test('handles small lists without negative ranges', () => { + expect(getVisibleRangeStart({ selectedIndex: 2, maxVisibleItems: 10, totalItems: 3 })).toBe(0); + expect(getVisibleRangeStart({ selectedIndex: 0, maxVisibleItems: 0, totalItems: 3 })).toBe(0); + }); +}); diff --git a/apps/cli/src/tui/components/resume-thread-modal.lib.ts b/apps/cli/src/tui/components/resume-thread-modal.lib.ts new file mode 100644 index 00000000..61a438dc --- /dev/null +++ b/apps/cli/src/tui/components/resume-thread-modal.lib.ts @@ -0,0 +1,15 @@ +export const normalizeResumeThreadLabel = (title?: string) => { + const label = title?.trim() ? title.trim() : 'Untitled thread'; + return label.replace(/\s+/g, ' '); +}; + +export const getVisibleRangeStart = (input: { + selectedIndex: number; + maxVisibleItems: number; + totalItems: number; +}) => { + const maxVisibleItems = Math.max(1, input.maxVisibleItems); + const maxStart = Math.max(input.totalItems - maxVisibleItems, 0); + const centeredStart = input.selectedIndex - Math.floor(maxVisibleItems / 2); + return Math.max(0, Math.min(centeredStart, maxStart)); +}; diff --git a/apps/cli/src/tui/components/resume-thread-modal.tsx b/apps/cli/src/tui/components/resume-thread-modal.tsx index 88c997c6..f84c6671 100644 --- a/apps/cli/src/tui/components/resume-thread-modal.tsx +++ b/apps/cli/src/tui/components/resume-thread-modal.tsx @@ -1,8 +1,9 @@ import { useEffect, useMemo, useState } from 'react'; -import { useKeyboard, useTerminalDimensions } from '@opentui/react'; +import { useKeyboard } from '@opentui/react'; import { colors } from '../theme.ts'; import { listThreads, type ThreadSummary } from '../thread-store.ts'; +import { getVisibleRangeStart, normalizeResumeThreadLabel } from './resume-thread-modal.lib.ts'; interface ResumeThreadModalProps { onSelect: (threadId: string) => void; @@ -15,36 +16,19 @@ export const ResumeThreadModal = (props: ResumeThreadModalProps) => { const [selectedIndex, setSelectedIndex] = useState(0); const [loadError, setLoadError] = useState(null); const maxVisibleItems = Math.max(1, props.maxVisibleItems); - const terminalDimensions = useTerminalDimensions(); const visibleRange = useMemo(() => { - const start = Math.max( - 0, - Math.min( - selectedIndex - Math.floor(maxVisibleItems / 2), - Math.max(threads.length - maxVisibleItems, 0) - ) - ); + const start = getVisibleRangeStart({ + selectedIndex, + maxVisibleItems, + totalItems: threads.length + }); return { start, threads: threads.slice(start, start + maxVisibleItems) }; }, [maxVisibleItems, selectedIndex, threads]); - const truncate = (value: string, maxLength: number) => { - if (value.length <= maxLength) return value; - if (maxLength <= 1) return '…'; - return `${value.slice(0, maxLength - 1)}…`; - }; - - // Terminal UIs often need explicit padding to overwrite prior longer lines. - // Some renderers trim trailing ASCII spaces, so we pad with NBSPs to force full-width overwrites. - const fit = (value: string, width: number) => truncate(value, width).padEnd(width, '\u00A0'); - - const contentWidth = Math.max(1, terminalDimensions.width - 4); // border + padding (approx) - const titleWidth = Math.max(10, Math.floor(contentWidth * 0.6)); - const dateWidth = Math.max(10, contentWidth - titleWidth); - useEffect(() => { let canceled = false; void (async () => { @@ -102,7 +86,6 @@ export const ResumeThreadModal = (props: ResumeThreadModalProps) => { backgroundColor: colors.bgSubtle, border: true, borderColor: colors.accent, - height: maxVisibleItems + 3, flexDirection: 'column', padding: 1 }} @@ -113,20 +96,24 @@ export const ResumeThreadModal = (props: ResumeThreadModalProps) => { visibleRange.threads.map((thread, i) => { const actualIndex = visibleRange.start + i; const isSelected = actualIndex === selectedIndex; - const label = thread.title?.trim() ? thread.title.trim() : 'Untitled thread'; + const label = normalizeResumeThreadLabel(thread.title); const lastActive = new Date(thread.lastActivityAt).toLocaleString(); - const prefix = isSelected ? '▸ ' : ' '; + const prefix = isSelected ? '> ' : ' '; return ( ); diff --git a/apps/cli/src/tui/lib/open-browser.ts b/apps/cli/src/tui/lib/open-browser.ts new file mode 100644 index 00000000..9e05ce5d --- /dev/null +++ b/apps/cli/src/tui/lib/open-browser.ts @@ -0,0 +1,16 @@ +import { spawn } from 'bun'; + +export const openBrowser = async (url: string) => { + if (process.platform === 'darwin') { + const proc = spawn(['open', url], { stdout: 'ignore', stderr: 'ignore' }); + await proc.exited; + return; + } + if (process.platform === 'win32') { + const proc = spawn(['cmd', '/c', 'start', '', url], { stdout: 'ignore', stderr: 'ignore' }); + await proc.exited; + return; + } + const proc = spawn(['xdg-open', url], { stdout: 'ignore', stderr: 'ignore' }); + await proc.exited; +}; diff --git a/apps/cli/src/tui/services.ts b/apps/cli/src/tui/services.ts index bc2e4d9d..76e5e519 100644 --- a/apps/cli/src/tui/services.ts +++ b/apps/cli/src/tui/services.ts @@ -104,6 +104,9 @@ export const services = { const streamResult = await Result.tryPromise(async () => { for await (const event of parseSSEStream(response)) { if (signal.aborted) break; + if (event.type === 'error') { + throw new Error(formatTuiStreamError(event)); + } if (event.type === 'done') { doneEvent = event; continue; @@ -248,4 +251,13 @@ function processStreamEvent( } } +const formatTuiStreamError = (event: Extract) => { + const authError = + event.tag === 'ProviderNotAuthenticatedError' || event.message.includes('is not authenticated'); + const hint = authError + ? 'Run /connect to authenticate this provider, then try again.' + : event.hint; + return hint ? `${event.message}\n\nHint: ${hint}` : event.message; +}; + export type Services = typeof services; diff --git a/apps/docs/btca.spec.md b/apps/docs/btca.spec.md index ff0ffa89..239dba51 100644 --- a/apps/docs/btca.spec.md +++ b/apps/docs/btca.spec.md @@ -1,11 +1,10 @@ -# btca API + CLI Spec (Local + Remote) +# btca API + CLI Spec (Local + Cloud APIs) This document is an audit‑ready reference for btca v2 covering: - Local server HTTP API (btca-server) - Local CLI commands (btca) -- Remote CLI commands (btca remote) -- Remote cloud APIs used by the CLI +- Cloud APIs used by hosted integrations - Authentication and installation - Configuration files, validation, and limits @@ -78,24 +77,9 @@ Environment variable overrides: - Removes provider entry from OpenCode auth file (env vars remain). -### 2.3 Remote (cloud) auth +### 2.3 Cloud API auth -Remote commands require an API key stored at: - -``` -~/.config/btca/remote-auth.json -``` - -Structure: - -```json -{ - "apiKey": "btca_xxxxxxxxxxxx", - "linkedAt": 1706000000000 -} -``` - -All remote requests include: +Cloud API requests include: ``` Authorization: Bearer @@ -157,40 +141,6 @@ Data storage: - Resources are stored in `${dataDirectory}/resources`. - If `dataDirectory` is missing and a legacy `.btca/` directory exists, the project config is migrated to use `.btca`. -### 3.2 Remote config: `btca.remote.config.jsonc` - -- File: `./btca.remote.config.jsonc` -- Remote supports **git resources only**. - -Example: - -```jsonc -{ - "$schema": "https://btca.dev/btca.remote.schema.json", - "project": "my-project", - "model": "claude-sonnet", - "resources": [ - { - "type": "git", - "name": "svelte", - "url": "https://github.com/sveltejs/svelte.dev", - "branch": "main", - "searchPath": "apps/svelte.dev", - "specialNotes": "Focus on docs" - } - ] -} -``` - -Remote model list (fixed): - -- `claude-sonnet` -- `claude-haiku` -- `gpt-4o` -- `gpt-4o-mini` - ---- - ## 4. CLI Spec (Local) ### 4.1 Global options @@ -325,13 +275,9 @@ Options: Behavior: -- Prompts for setup type: **MCP** (remote) or **CLI** (local) -- MCP path: - - Prompts for API key (if missing), validates it - - Creates `btca.remote.config.jsonc` -- CLI path: - - Creates `btca.config.jsonc` - - Handles `.btca/` and `.gitignore` +- Creates `btca.config.jsonc` +- Prompts for storage mode (`local` `.btca/` or global) +- Handles `.btca/` and `.gitignore` when local storage is selected ### 4.11 `btca clear` @@ -368,112 +314,6 @@ Behavior: - Prompts for an editor (Cursor, OpenCode, Codex, Claude Code). - Writes a project config entry for that editor. -### 4.15 `btca mcp remote` - -Scaffolds MCP configuration for the remote btca server. - -Behavior: - -- Prompts for an editor (Cursor, OpenCode, Codex, Claude Code). -- Writes a project config entry with a stub API key. -- Prints a link to fetch a real API key. - -## 5. CLI Spec (Remote) - -All remote commands require authentication via `btca remote link`. - -### 5.1 `btca remote link` - -Authenticate with btca cloud API. - -Options: - -- `--key ` - -Behavior: - -- Prompts for API key if not provided. -- Validates key by calling MCP listResources. - -### 5.2 `btca remote unlink` - -Removes stored API key. - -### 5.3 `btca remote status` - -Shows sandbox state, plan, version, and current project info. - -### 5.4 `btca remote wake` - -Pre‑warms sandbox and returns when ready. - -### 5.5 `btca remote add [url]` - -Adds a git resource to local remote config and syncs to cloud. - -Options: - -- `-n, --name ` -- `-b, --branch ` -- `-s, --search-path ` -- `--notes ` - -Behavior: - -- Creates local config if missing (prompts for project name). -- Normalizes GitHub URLs. -- Syncs resource to cloud; warns if sync fails. - -### 5.6 `btca remote sync` - -Syncs local remote config with cloud. - -Options: - -- `--force` — overwrite cloud on conflicts - -Behavior: - -- Detects conflicts (same resource name but different config). - -### 5.7 `btca remote ask` - -Ask a question via cloud sandbox. - -Options: - -- `-q, --question ` **required** -- `-r, --resource ` - -Behavior: - -- Validates resources by calling `listResources` first. -- If none specified, uses all available resources. - -### 5.8 `btca remote grab ` - -Fetch full thread transcript. - -Options: - -- `--json` -- `--markdown` (default) - -### 5.9 `btca remote init` - -Creates `btca.remote.config.jsonc`. - -Options: - -- `-p, --project ` - -### 5.10 `btca remote mcp [agent]` - -Outputs MCP configuration snippet: - -- `opencode`: JSON config block -- `claude`: CLI command for Claude Code - --- ## 6. Local Server HTTP API (btca-server) @@ -810,7 +650,7 @@ Git URL validation: --- -## 9. Remote Cloud API (used by CLI) +## 9. Remote Cloud API All remote API calls require: @@ -881,8 +721,5 @@ Response shape: ## 10. Known Gaps / Audit Notes - `--global` flags exist on several commands, but the effective target is determined by whether a project config exists; there is no strict global override path. -- `btca remote add` defaults differ between paths: - - Interactive path uses model `claude-haiku`. - - Non‑interactive path uses model `claude-sonnet`. --- diff --git a/apps/docs/guides/authentication.mdx b/apps/docs/guides/authentication.mdx index aa76669e..ab779c09 100644 --- a/apps/docs/guides/authentication.mdx +++ b/apps/docs/guides/authentication.mdx @@ -1,6 +1,6 @@ --- title: 'Authentication' -description: 'Connect providers locally and link your cloud API key' +description: 'Connect and manage local provider authentication' --- ## Local provider auth (btca-server) @@ -32,11 +32,15 @@ Environment variable overrides: ```bash btca connect btca disconnect +btca wipe ``` `btca connect` performs OAuth for `openai` and `github-copilot`, prompts for API keys when required, and falls back to `opencode auth --provider ` for other providers. +`btca wipe` removes BTCA config files in the current directory and global BTCA config files +(with a confirmation prompt unless `--yes` is passed). + For `github-copilot`, btca uses device flow OAuth and opens a browser prompt to complete the sign-in. @@ -48,32 +52,3 @@ For `openai-compat`, `btca connect` also collects the required provider details: OpenAI-compatible provider. - **Model ID** (required): the model to use for requests; stored in `btca.config.jsonc` as `model`. - **API key** (optional): only if your server requires authentication; stored in OpenCode auth. - -## Cloud auth (btca remote) - -Remote commands require an API key stored at: - -``` -~/.config/btca/remote-auth.json -``` - -Example: - -```json -{ - "apiKey": "btca_xxxxxxxxxxxx", - "linkedAt": 1706000000000 -} -``` - -Use the CLI to link: - -```bash -btca remote link -``` - -All cloud requests send: - -``` -Authorization: Bearer -``` diff --git a/apps/docs/guides/cli-reference.mdx b/apps/docs/guides/cli-reference.mdx index eedd5bbc..223ffafa 100644 --- a/apps/docs/guides/cli-reference.mdx +++ b/apps/docs/guides/cli-reference.mdx @@ -118,6 +118,28 @@ For `openai-compat`, the interactive flow additionally prompts for: - Model ID (required): saved as `model` in `btca.config.jsonc`. - API key (optional): only if your server requires auth, stored in OpenCode auth. +## `btca status` + +Shows current btca status. + +Output includes: + +- selected model +- selected provider +- selected model source (`project`, `global`, or `default`) +- selected provider source (`project`, `global`, or `default`) +- whether the selected provider is authenticated +- resources from `~/.config/btca/btca.config.jsonc` +- resources from `./btca.config.jsonc` (if the file exists) +- installed btca version and latest npm version +- update hint if your version is behind + +If an update is available, it prints: + +```bash +Update available: run "bun add -g btca@latest" +``` + ## `btca disconnect` Disconnects provider credentials. @@ -140,12 +162,27 @@ Options: - `-f, --force` overwrites existing config. -Behavior: Prompts for setup type: MCP (remote) or CLI (local). The MCP path validates an API key if needed and creates `btca.remote.config.jsonc`. The CLI path creates `btca.config.jsonc` and handles `.btca/` and `.gitignore`. +Behavior: Creates `btca.config.jsonc` and prompts for storage mode (`local` `.btca/` or global). If local storage is selected, it also handles `.gitignore` updates for `.btca/`. ## `btca clear` Clears all locally cloned resources. +## `btca wipe` + +Permanently deletes BTCA config files for the current directory and global config. + +Options: + +- `-y, --yes` skips the interactive confirmation prompt. + +Behavior: + +- Targets `./btca.config.jsonc`. +- Targets `~/.config/btca/btca.config.jsonc`. +- Prints removed, missing, and failed paths. +- Shows a confirmation prompt unless `--yes` is passed. + ## `btca serve` Starts the local server. @@ -161,7 +198,6 @@ Runs the local MCP server over stdio by default. Subcommands: - `btca mcp local` scaffolds editor config for local stdio MCP. -- `btca mcp remote` scaffolds editor config for remote HTTP MCP (stubs an API key). Behavior: @@ -172,84 +208,3 @@ Tools: - `listResources` - list local resources. - `ask` - ask a question against local resources. - -## Remote commands (`btca remote`) - -All remote commands require an API key linked with `btca remote link`. - -### `btca remote link` - -Authenticates with the btca cloud API. - -Options: - -- `--key ` provides the API key directly. - -Behavior: If `--key` is omitted, it prompts for a key and validates it by calling MCP `listResources`. - -### `btca remote unlink` - -Removes the stored API key. - -### `btca remote status` - -Shows sandbox state, plan, version, and current project info. - -### `btca remote wake` - -Pre-warms the sandbox and returns when ready. - -### `btca remote add [url]` - -Adds a git resource to local remote config and syncs to the cloud. - -Options: - -- `-n, --name ` sets a resource name. -- `-b, --branch ` sets a branch. -- `-s, --search-path ` sets one or more search paths. -- `--notes ` sets special notes. - -Behavior: Creates local config if missing and prompts for a project name. GitHub URLs are normalized. Syncs to cloud and warns if the sync fails. - -### `btca remote sync` - -Syncs local remote config with the cloud. - -Options: - -- `--force` overwrites cloud config on conflicts. - -Behavior: Detects conflicts when a resource name exists with different settings. - -### `btca remote ask` - -Asks a question via the cloud sandbox. - -Options: - -- `-q, --question ` is required. -- `-r, --resource ` can be repeated. - -Behavior: Validates resources by calling `listResources`. If none are specified, it uses all available resources. - -### `btca remote grab ` - -Fetches the full thread transcript. - -Options: - -- `--json` outputs JSON. -- `--markdown` outputs Markdown (default). - -### `btca remote init` - -Creates `btca.remote.config.jsonc`. - -Options: - -- `-p, --project ` sets the project name. - -### `btca remote mcp [agent]` - -Outputs an MCP configuration snippet. Supported agents are `opencode` (JSON config block) and `claude` (Claude Code CLI command). diff --git a/apps/docs/guides/configuration.mdx b/apps/docs/guides/configuration.mdx index 2bf690fa..bc42080b 100644 --- a/apps/docs/guides/configuration.mdx +++ b/apps/docs/guides/configuration.mdx @@ -1,6 +1,6 @@ --- title: 'Configuration' -description: 'Local and remote config files, defaults, and validation limits' +description: 'Local config files, defaults, and validation limits' --- ## Local config: `btca.config.jsonc` @@ -64,37 +64,6 @@ Defaults if the global config is missing: If `dataDirectory` is missing and a legacy `.btca/` exists, the project config is migrated to use `.btca`. -## Remote config: `btca.remote.config.jsonc` - -Location: - -- Project: `./btca.remote.config.jsonc` - -Notes: - -- Remote supports **git resources only**. -- Remote model list: `claude-sonnet`, `claude-haiku`, `gpt-4o`, `gpt-4o-mini`. - -Example: - -```jsonc -{ - "$schema": "https://btca.dev/btca.remote.schema.json", - "project": "my-project", - "model": "claude-sonnet", - "resources": [ - { - "type": "git", - "name": "svelte", - "url": "https://github.com/sveltejs/svelte.dev", - "branch": "main", - "searchPath": "apps/svelte.dev", - "specialNotes": "Focus on docs" - } - ] -} -``` - ## Validation limits - Resource name: max 64 chars, regex `^@?[a-zA-Z0-9][a-zA-Z0-9._-]*(/[a-zA-Z0-9][a-zA-Z0-9._-]*)*$`, no `..`, no `//`, no trailing `/` @@ -114,6 +83,5 @@ Example: - btca tries `main`, `master`, `trunk`, then `dev` by default. - If your repo uses a different default branch, add it as a named resource instead of using a one-off URL. -- `btca remote add` defaults differ between paths. Interactive path uses model `claude-haiku`. Non-interactive path uses model `claude-sonnet`. - `--global` flags exist on several commands, but the effective target is determined by whether a project config exists; there is no strict global override. diff --git a/apps/docs/guides/mcp.mdx b/apps/docs/guides/mcp.mdx index d827aa6c..e293e363 100644 --- a/apps/docs/guides/mcp.mdx +++ b/apps/docs/guides/mcp.mdx @@ -13,7 +13,8 @@ Use the btca MCP server to query resources from your agent of choice. There are To scaffold configs automatically, run: - `btca mcp local` -- `btca mcp remote` + +For cloud MCP, use the manual config snippets below with your API key. ## Add to `AGENTS.md` diff --git a/apps/docs/guides/quickstart.mdx b/apps/docs/guides/quickstart.mdx index e377a59d..854454b1 100644 --- a/apps/docs/guides/quickstart.mdx +++ b/apps/docs/guides/quickstart.mdx @@ -27,8 +27,7 @@ From your repo root: btca init ``` -Choose **CLI** for local resources or **MCP** for cloud resources. The wizard writes -`btca.config.jsonc` or `btca.remote.config.jsonc`. +The wizard creates `btca.config.jsonc` for local usage. ## 4) Add a resource diff --git a/apps/server/src/agent/loop.ts b/apps/server/src/agent/loop.ts index 23c7816e..463e33e1 100644 --- a/apps/server/src/agent/loop.ts +++ b/apps/server/src/agent/loop.ts @@ -61,10 +61,19 @@ export namespace AgentLoop { '- list: List directory contents', '', 'Guidelines:', - '- Use glob to find relevant files first, then read them', - '- Use grep to search for specific code patterns or text', - '- Always cite the source files in your answers', - '- Be concise but thorough in your responses', + '- Ground answers in the loaded resources. Do not rely on unstated prior knowledge.', + '- Search efficiently: start with one focused list/glob pass, then read likely files; only expand search when evidence is insufficient.', + '- Prefer targeted grep/read over broad repeated scans once candidate files are known.', + '- Give clear, unambiguous answers. State assumptions, prerequisites, and important version-sensitive caveats.', + '- For implementation/how-to questions, provide complete step-by-step instructions with commands and code snippets.', + '- Be concise but thorough in your responses.', + '- End every answer with a "Sources" section.', + '- For git resources, source links must be full GitHub blob URLs.', + '- In "Sources", format git citations as markdown links: "- [repo/relative/path.ext](https://github.com/.../blob/.../repo/relative/path.ext)".', + '- Do not use raw URLs as link labels.', + '- Do not repeat a URL in parentheses after a link.', + '- Do not output sources in "url (url)" format.', + '- For local resources, cite local file paths (no GitHub URL required).', '- If you cannot find the answer, say so clearly', '', agentInstructions diff --git a/apps/server/src/collections/service.ts b/apps/server/src/collections/service.ts index 090dd313..be2e73af 100644 --- a/apps/server/src/collections/service.ts +++ b/apps/server/src/collections/service.ts @@ -25,14 +25,39 @@ export namespace Collections { }) => Promise; }; - const createCollectionInstructionBlock = (resource: BtcaFsResource): string => { + const encodePathSegments = (value: string) => value.split('/').map(encodeURIComponent).join('/'); + + const trimGitSuffix = (url: string) => url.replace(/\.git$/u, '').replace(/\/+$/u, ''); + + const createCollectionInstructionBlock = ( + resource: BtcaFsResource, + metadata?: VirtualResourceMetadata + ): string => { const focusLines = resource.repoSubPaths.map( (subPath) => `Focus: ./${resource.fsName}/${subPath}` ); + const gitRef = metadata?.branch ?? metadata?.commit; + const githubPrefix = + resource.type === 'git' && metadata?.url && gitRef + ? `${trimGitSuffix(metadata.url)}/blob/${encodeURIComponent(gitRef)}` + : undefined; const lines = [ `## Resource: ${resource.name}`, FS_RESOURCE_SYSTEM_NOTE, `Path: ./${resource.fsName}`, + resource.type === 'git' && metadata?.url ? `Repo URL: ${trimGitSuffix(metadata.url)}` : '', + resource.type === 'git' && metadata?.branch ? `Repo Branch: ${metadata.branch}` : '', + resource.type === 'git' && metadata?.commit ? `Repo Commit: ${metadata.commit}` : '', + githubPrefix ? `GitHub Blob Prefix: ${githubPrefix}` : '', + githubPrefix + ? `GitHub Citation Rule: Convert virtual paths under ./${resource.fsName}/ to repo-relative paths, then encode each path segment for GitHub URLs (example segment: "+page.server.js" -> "${encodeURIComponent('+page.server.js')}").` + : '', + githubPrefix + ? `GitHub Citation Example: ${githubPrefix}/${encodePathSegments('src/routes/blog/+page.server.js')}` + : '', + resource.type === 'local' + ? 'Citation Rule: Cite local file paths only for this resource (no GitHub URL).' + : '', ...focusLines, resource.specialAgentInstructions ? `Notes: ${resource.specialAgentInstructions}` : '' ].filter(Boolean); @@ -267,7 +292,12 @@ export namespace Collections { resources: metadataResources }); - const instructionBlocks = loadedResources.map(createCollectionInstructionBlock); + const metadataByName = new Map( + metadataResources.map((resource) => [resource.name, resource]) + ); + const instructionBlocks = loadedResources.map((resource) => + createCollectionInstructionBlock(resource, metadataByName.get(resource.name)) + ); return Result.ok({ path: collectionPath, diff --git a/apps/server/src/config/index.ts b/apps/server/src/config/index.ts index 59387b6b..548e6a5b 100644 --- a/apps/server/src/config/index.ts +++ b/apps/server/src/config/index.ts @@ -71,6 +71,7 @@ const StoredConfigSchema = z.object({ type StoredConfig = z.infer; type ProviderOptionsConfig = z.infer; type ProviderOptionsMap = z.infer; +type ConfigScope = 'project' | 'global'; // Legacy config schemas (btca.json format from old CLI) // There are two legacy formats: @@ -137,7 +138,7 @@ export namespace Config { provider: string, model: string, providerOptions?: ProviderOptionsConfig - ) => Promise<{ provider: string; model: string }>; + ) => Promise<{ provider: string; model: string; savedTo: ConfigScope }>; addResource: (resource: ResourceDefinition) => Promise; removeResource: (name: string) => Promise; clearResources: () => Promise<{ cleared: number }>; @@ -689,7 +690,11 @@ export namespace Config { setMutableConfig(updated); await saveConfig(configPath, updated); Metrics.info('config.model.updated', { provider, model }); - return { provider, model }; + return { + provider, + model, + savedTo: currentProjectConfig ? 'project' : 'global' + }; }, addResource: async (resource: ResourceDefinition) => { diff --git a/apps/server/src/stream/service.ts b/apps/server/src/stream/service.ts index 41f1aae6..4a0d9f04 100644 --- a/apps/server/src/stream/service.ts +++ b/apps/server/src/stream/service.ts @@ -1,7 +1,7 @@ import { stripUserQuestionFromStart, extractCoreQuestion } from '@btca/shared'; import { Result } from 'better-result'; -import { getErrorMessage, getErrorTag } from '../errors.ts'; +import { getErrorHint, getErrorMessage, getErrorTag } from '../errors.ts'; import { Metrics } from '../metrics/index.ts'; import type { AgentLoop } from '../agent/loop.ts'; @@ -304,7 +304,8 @@ export namespace StreamService { const err: BtcaStreamErrorEvent = { type: 'error', tag: getErrorTag(event.error), - message: getErrorMessage(event.error) + message: getErrorMessage(event.error), + hint: getErrorHint(event.error) }; emit(controller, err); break; @@ -323,7 +324,8 @@ export namespace StreamService { const err: BtcaStreamErrorEvent = { type: 'error', tag: getErrorTag(cause), - message: getErrorMessage(cause) + message: getErrorMessage(cause), + hint: getErrorHint(cause) }; emit(controller, err); } diff --git a/apps/server/src/stream/types.ts b/apps/server/src/stream/types.ts index badb7639..32553f37 100644 --- a/apps/server/src/stream/types.ts +++ b/apps/server/src/stream/types.ts @@ -129,7 +129,8 @@ export const BtcaStreamDoneEventSchema = z.object({ export const BtcaStreamErrorEventSchema = z.object({ type: z.literal('error'), tag: z.string(), - message: z.string() + message: z.string(), + hint: z.string().optional() }); export const BtcaStreamEventSchema = z.union([ diff --git a/apps/web/src/lib/components/ChatMessages.svelte b/apps/web/src/lib/components/ChatMessages.svelte index ab9fe7d3..01f6552a 100644 --- a/apps/web/src/lib/components/ChatMessages.svelte +++ b/apps/web/src/lib/components/ChatMessages.svelte @@ -192,10 +192,22 @@ }; const html = (await marked.parse(content, { async: true, renderer })) as string; - return DOMPurify.sanitize(html, { + const sanitizedHtml = DOMPurify.sanitize(html, { ADD_TAGS: ['pre', 'code'], ADD_ATTR: ['data-code-id', 'data-copy-target', 'onclick', 'class', 'id', 'style'] }); + return ensureChatLinksOpenInNewTab(sanitizedHtml); + } + + function ensureChatLinksOpenInNewTab(html: string): string { + return html.replace(/]*>/gi, (match) => { + const withTarget = /\s+target\s*=\s*(["'].*?["']|[^\s>]+)/i.test(match) + ? match.replace(/\s+target\s*=\s*(["'].*?["']|[^\s>]+)/i, ' target="_blank"') + : match.replace(/>$/, ' target="_blank">'); + return /\s+rel\s*=\s*(["'].*?["']|[^\s>]+)/i.test(withTarget) + ? withTarget.replace(/\s+rel\s*=\s*(["'].*?["']|[^\s>]+)/i, ' rel="noopener noreferrer"') + : withTarget.replace(/>$/, ' rel="noopener noreferrer">'); + }); } function getRenderedMarkdown(text: string): string { @@ -214,10 +226,11 @@ } const html = marked.parse(content, { async: false }) as string; - return DOMPurify.sanitize(html, { + const sanitizedHtml = DOMPurify.sanitize(html, { ADD_TAGS: ['pre', 'code'], ADD_ATTR: ['class'] }); + return ensureChatLinksOpenInNewTab(sanitizedHtml); } // Global copy function diff --git a/btca.config.jsonc b/btca.config.jsonc deleted file mode 100644 index fd6fe3da..00000000 --- a/btca.config.jsonc +++ /dev/null @@ -1,138 +0,0 @@ -{ - "$schema": "https://btca.dev/btca.schema.json", - "dataDirectory": ".btca", - "resources": [ - { - "type": "git", - "name": "runed", - "url": "https://github.com/svecosystem/runed", - "branch": "main" - }, - { - "type": "git", - "name": "justBash", - "url": "https://github.com/vercel-labs/just-bash", - "branch": "main" - }, - { - "type": "git", - "name": "autumn", - "url": "https://github.com/useautumn/typescript", - "branch": "main", - "specialNotes": "this is the TS SDK and cli for autumn. the cli is in atmn/ and the important sdk stuff is in package/" - }, - { - "type": "git", - "name": "convexWorkpools", - "url": "https://github.com/get-convex/workpool", - "branch": "main", - "specialNotes": "This is a Convex component that does work pools. Work pools are basically background jobs with proper queuing setup, retries, and all that stuff. " - }, - { - "type": "git", - "name": "daytona", - "url": "https://github.com/daytonaio/daytona", - "branch": "main", - "specialNotes": "this is the full daytona codebase. focus on the guides and examples for answers" - }, - { - "type": "git", - "name": "svelte", - "url": "https://github.com/sveltejs/svelte.dev", - "branch": "main", - "searchPaths": [ - "apps/svelte.dev" - ], - "specialNotes": "Svelte docs website. Focus on content directory for markdown documentation." - }, - { - "type": "git", - "name": "svelteKit", - "url": "https://github.com/sveltejs/kit", - "branch": "main", - "searchPath": "documentation" - }, - { - "type": "git", - "name": "tailwind", - "url": "https://github.com/tailwindlabs/tailwindcss.com", - "branch": "main", - "searchPath": "src/docs" - }, - { - "type": "git", - "name": "hono", - "url": "https://github.com/honojs/website", - "branch": "main", - "searchPath": "docs" - }, - { - "type": "git", - "name": "zod", - "url": "https://github.com/colinhacks/zod", - "branch": "main", - "searchPath": "packages/docs/content" - }, - { - "type": "git", - "name": "solidJs", - "url": "https://github.com/solidjs/solid-docs", - "branch": "main", - "searchPath": "src/routes" - }, - { - "type": "git", - "name": "commander", - "url": "https://github.com/tj/commander.js", - "branch": "master", - "searchPath": "docs" - }, - { - "type": "git", - "name": "vite", - "url": "https://github.com/vitejs/vite", - "branch": "main", - "searchPath": "docs" - }, - { - "type": "git", - "name": "opencode", - "url": "https://github.com/anomalyco/opencode", - "branch": "dev" - }, - { - "type": "git", - "name": "clerk", - "url": "https://github.com/clerk/javascript", - "branch": "main" - }, - { - "type": "git", - "name": "convexJs", - "url": "https://github.com/get-convex/convex-js", - "branch": "main" - }, - { - "type": "git", - "name": "convexDocs", - "url": "https://github.com/get-convex/convex-docs", - "branch": "main", - "specialNotes": "Official Convex documentation. Use for HTTP actions, queries, mutations, actions, schema, etc." - }, - { - "type": "git", - "name": "daytonaSdk", - "url": "https://github.com/daytonaio/sdk", - "branch": "main", - "specialNotes": "Daytona TypeScript SDK. Use for sandbox creation, management, and API reference." - }, - { - "type": "git", - "name": "convex", - "url": "https://github.com/get-convex/convex-js", - "branch": "main" - } - ], - "model": "gpt-5.3-codex-spark", - "provider": "openai" -} \ No newline at end of file diff --git a/package.json b/package.json index 357705fc..6e4dfda7 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "format:sandbox": "turbo format --filter=btca-sandbox", "test:all": "turbo test", "test:server": "turbo test --filter=btca-server", + "wipe:btca": "bun run apps/cli/src/index.ts wipe", "clean": "find . -type d \\( -name node_modules -o -name .svelte-kit -o -name .turbo -o -name .vercel \\) -prune -exec rm -rf {} +" }, "devDependencies": {