├── apps ├── web │ ├── .npmrc │ ├── static │ │ └── robots.txt │ ├── .vscode │ │ └── settings.json │ ├── src │ │ ├── lib │ │ │ ├── assets │ │ │ │ ├── og.png │ │ │ │ └── favicon.svg │ │ │ ├── CopyButton.svelte │ │ │ └── stores │ │ │ │ ├── ShikiStore.svelte.ts │ │ │ │ └── ThemeStore.svelte.ts │ │ ├── app.d.ts │ │ ├── routes │ │ │ ├── layout.css │ │ │ ├── og │ │ │ │ └── +page.svelte │ │ │ ├── models │ │ │ │ └── +page.svelte │ │ │ ├── commands │ │ │ │ └── +page.svelte │ │ │ ├── +layout.svelte │ │ │ ├── +page.svelte │ │ │ └── getting-started │ │ │ │ └── +page.svelte │ │ └── app.html │ ├── .prettierignore │ ├── vite.config.ts │ ├── .gitignore │ ├── .prettierrc │ ├── svelte.config.js │ ├── tsconfig.json │ ├── README.md │ └── package.json └── cli │ ├── src │ ├── tui │ │ ├── index.tsx │ │ ├── types.ts │ │ ├── theme.ts │ │ ├── clipboard.ts │ │ ├── commands.ts │ │ ├── components │ │ │ ├── RemoveRepoPrompt.tsx │ │ │ ├── CommandPalette.tsx │ │ │ ├── RepoSelector.tsx │ │ │ ├── ModelConfig.tsx │ │ │ └── AddRepoWizard.tsx │ │ ├── services.ts │ │ └── App.tsx │ ├── sandbox │ │ ├── index.tsx │ │ ├── commands.ts │ │ ├── AddRepoPrompt.tsx │ │ ├── RepoSelector.tsx │ │ ├── CommandPalette.tsx │ │ └── App.tsx │ ├── index.ts │ ├── lib │ │ ├── errors.ts │ │ ├── utils │ │ │ ├── git.ts │ │ │ ├── files.ts │ │ │ └── validation.ts │ │ └── prompts.ts │ └── services │ │ ├── oc.ts │ │ ├── config.ts │ │ └── cli.ts │ ├── scripts │ ├── build.ts │ └── build-binaries.ts │ ├── .gitignore │ ├── tsconfig.json │ ├── bin.js │ ├── package.json │ └── README.md ├── .prettierrc ├── .prettierignore ├── packages └── shared │ ├── README.md │ ├── package.json │ ├── .gitignore │ ├── tsconfig.json │ └── src │ └── index.ts ├── turbo.json ├── .vscode └── settings.json ├── .gitignore ├── LICENSE.md ├── tsconfig.json ├── package.json ├── README.md └── AGENTS.md /apps/web/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /apps/web/static/robots.txt: -------------------------------------------------------------------------------- 1 | # allow crawling everything by default 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /apps/web/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "*.css": "tailwindcss" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /apps/web/src/lib/assets/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davis7dotsh/better-context/HEAD/apps/web/src/lib/assets/og.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /apps/web/.prettierignore: -------------------------------------------------------------------------------- 1 | # Package Managers 2 | package-lock.json 3 | pnpm-lock.yaml 4 | yarn.lock 5 | bun.lock 6 | bun.lockb 7 | 8 | # Miscellaneous 9 | /static/ 10 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Package Managers 2 | package-lock.json 3 | pnpm-lock.yaml 4 | yarn.lock 5 | bun.lock 6 | bun.lockb 7 | 8 | # Miscellaneous 9 | /static/ 10 | 11 | .svelte-kit 12 | .turbo -------------------------------------------------------------------------------- /packages/shared/README.md: -------------------------------------------------------------------------------- 1 | # shared 2 | 3 | To install dependencies: 4 | 5 | ```bash 6 | bun install 7 | ``` 8 | 9 | To run: 10 | 11 | ```bash 12 | bun run index.ts 13 | ``` 14 | 15 | This project was created using `bun init` in bun v1.3.4. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. 16 | -------------------------------------------------------------------------------- /apps/web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import devtoolsJson from 'vite-plugin-devtools-json'; 2 | import tailwindcss from '@tailwindcss/vite'; 3 | import { sveltekit } from '@sveltejs/kit/vite'; 4 | import { defineConfig } from 'vite'; 5 | 6 | export default defineConfig({ 7 | plugins: [tailwindcss(), sveltekit(), devtoolsJson()] 8 | }); 9 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.com/schema.json", 3 | "tasks": { 4 | "build": { 5 | "outputs": [".svelte-kit/**", "dist/**"] 6 | }, 7 | "dev": { 8 | "persistent": true, 9 | "cache": false 10 | }, 11 | "check": { 12 | "cache": false 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /apps/web/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # Output 4 | .output 5 | .vercel 6 | .netlify 7 | .wrangler 8 | /.svelte-kit 9 | /build 10 | 11 | # OS 12 | .DS_Store 13 | Thumbs.db 14 | 15 | # Env 16 | .env 17 | .env.* 18 | !.env.example 19 | !.env.test 20 | 21 | # Vite 22 | vite.config.js.timestamp-* 23 | vite.config.ts.timestamp-* 24 | -------------------------------------------------------------------------------- /apps/cli/src/tui/index.tsx: -------------------------------------------------------------------------------- 1 | import { createCliRenderer } from '@opentui/core'; 2 | import { createRoot } from '@opentui/react'; 3 | import { App } from './App.tsx'; 4 | 5 | export async function launchTui() { 6 | const renderer = await createCliRenderer({ 7 | exitOnCtrlC: true 8 | }); 9 | 10 | createRoot(renderer).render(); 11 | } 12 | -------------------------------------------------------------------------------- /apps/web/src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://svelte.dev/docs/kit/types#app.d.ts 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface PageState {} 9 | // interface Platform {} 10 | } 11 | } 12 | 13 | export {}; 14 | -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@btca/shared", 3 | "version": "0.0.1", 4 | "module": "src/index.ts", 5 | "type": "module", 6 | "private": true, 7 | "exports": { 8 | ".": "./src/index.ts" 9 | }, 10 | "devDependencies": { 11 | "@types/bun": "latest" 12 | }, 13 | "peerDependencies": { 14 | "typescript": "^5" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /apps/cli/src/sandbox/index.tsx: -------------------------------------------------------------------------------- 1 | import { createCliRenderer } from '@opentui/core'; 2 | import { createRoot } from '@opentui/react'; 3 | import { App } from './App.tsx'; 4 | 5 | async function main() { 6 | const renderer = await createCliRenderer({ 7 | exitOnCtrlC: true 8 | }); 9 | 10 | createRoot(renderer).render(); 11 | } 12 | 13 | main().catch(console.error); 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true, 4 | "workbench.colorCustomizations": { 5 | "titleBar.activeBackground": "#d35400", 6 | "titleBar.activeForeground": "#ffffff", 7 | "titleBar.inactiveBackground": "#d35400", 8 | "titleBar.inactiveForeground": "#ffffffcc" 9 | } 10 | } -------------------------------------------------------------------------------- /apps/cli/scripts/build.ts: -------------------------------------------------------------------------------- 1 | import packageJson from "../package.json"; 2 | 3 | const VERSION = packageJson.version; 4 | 5 | console.log(`Building btca v${VERSION}`); 6 | 7 | await Bun.build({ 8 | entrypoints: ["src/index.ts"], 9 | outdir: "dist", 10 | target: "bun", 11 | define: { 12 | __VERSION__: JSON.stringify(VERSION), 13 | }, 14 | }); 15 | 16 | console.log("Done"); 17 | -------------------------------------------------------------------------------- /apps/web/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-tailwindcss", "prettier-plugin-svelte"], 7 | "overrides": [ 8 | { 9 | "files": "*.svelte", 10 | "options": { 11 | "parser": "svelte" 12 | } 13 | } 14 | ], 15 | "tailwindStylesheet": "./src/routes/layout.css" 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/src/lib/assets/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /apps/web/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-vercel'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | preprocess: vitePreprocess(), 7 | 8 | kit: { 9 | adapter: adapter(), 10 | experimental: { 11 | remoteFunctions: true 12 | } 13 | }, 14 | compilerOptions: { 15 | experimental: { 16 | async: true 17 | } 18 | } 19 | }; 20 | 21 | export default config; 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies (bun install) 2 | node_modules 3 | 4 | # output 5 | out 6 | dist 7 | *.tgz 8 | 9 | .turbo 10 | 11 | # code coverage 12 | coverage 13 | *.lcov 14 | 15 | # logs 16 | logs 17 | _.log 18 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 19 | 20 | # dotenv environment variable files 21 | .env 22 | .env.development.local 23 | .env.test.local 24 | .env.production.local 25 | .env.local 26 | 27 | # caches 28 | .eslintcache 29 | .cache 30 | *.tsbuildinfo 31 | 32 | # IntelliJ based IDEs 33 | .idea 34 | 35 | # Finder (MacOS) folder config 36 | .DS_Store 37 | -------------------------------------------------------------------------------- /apps/cli/src/tui/types.ts: -------------------------------------------------------------------------------- 1 | export interface Repo { 2 | name: string; 3 | url: string; 4 | branch: string; 5 | specialNotes?: string; 6 | } 7 | 8 | export interface Message { 9 | role: 'user' | 'assistant' | 'system'; 10 | content: string; 11 | } 12 | 13 | export type Mode = 'chat' | 'add-repo' | 'remove-repo' | 'config-model' | 'loading'; 14 | 15 | export type CommandMode = 'add-repo' | 'remove-repo' | 'config-model' | 'chat' | 'ask' | 'clear'; 16 | 17 | export interface Command { 18 | name: string; 19 | description: string; 20 | mode: CommandMode; 21 | } 22 | -------------------------------------------------------------------------------- /packages/shared/.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies (bun install) 2 | node_modules 3 | 4 | # output 5 | out 6 | dist 7 | *.tgz 8 | 9 | # code coverage 10 | coverage 11 | *.lcov 12 | 13 | # logs 14 | logs 15 | _.log 16 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 17 | 18 | # dotenv environment variable files 19 | .env 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | .env.local 24 | 25 | # caches 26 | .eslintcache 27 | .cache 28 | *.tsbuildinfo 29 | 30 | # IntelliJ based IDEs 31 | .idea 32 | 33 | # Finder (MacOS) folder config 34 | .DS_Store 35 | -------------------------------------------------------------------------------- /apps/cli/.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies (bun install) 2 | node_modules 3 | 4 | dist-check 5 | 6 | # output 7 | out 8 | dist 9 | *.tgz 10 | 11 | # code coverage 12 | coverage 13 | *.lcov 14 | 15 | # logs 16 | logs 17 | _.log 18 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 19 | 20 | # dotenv environment variable files 21 | .env 22 | .env.development.local 23 | .env.test.local 24 | .env.production.local 25 | .env.local 26 | 27 | # caches 28 | .eslintcache 29 | .cache 30 | *.tsbuildinfo 31 | 32 | # IntelliJ based IDEs 33 | .idea 34 | 35 | # Finder (MacOS) folder config 36 | .DS_Store 37 | -------------------------------------------------------------------------------- /apps/cli/src/sandbox/commands.ts: -------------------------------------------------------------------------------- 1 | export interface Command { 2 | name: string; 3 | description: string; 4 | mode: 'add-repo' | 'select-repo'; 5 | } 6 | 7 | export const COMMANDS: Command[] = [ 8 | { 9 | name: 'add', 10 | description: 'Add a new repo', 11 | mode: 'add-repo' 12 | }, 13 | { 14 | name: 'repo', 15 | description: 'Switch to a different repo', 16 | mode: 'select-repo' 17 | } 18 | ]; 19 | 20 | export function filterCommands(query: string): Command[] { 21 | const normalizedQuery = query.toLowerCase(); 22 | return COMMANDS.filter((cmd) => cmd.name.toLowerCase().startsWith(normalizedQuery)); 23 | } 24 | -------------------------------------------------------------------------------- /apps/web/src/routes/layout.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | @plugin '@tailwindcss/typography'; 3 | 4 | @custom-variant dark (&:where(.dark, .dark *)); 5 | 6 | @layer base { 7 | :root { 8 | color-scheme: light; 9 | } 10 | 11 | :root.dark { 12 | color-scheme: dark; 13 | } 14 | 15 | html, 16 | body { 17 | height: 100%; 18 | } 19 | 20 | body { 21 | @apply bg-neutral-50 text-neutral-950 antialiased dark:bg-neutral-950 dark:text-neutral-50; 22 | } 23 | 24 | a { 25 | @apply underline decoration-neutral-300 underline-offset-4 hover:decoration-neutral-500 dark:decoration-neutral-700 dark:hover:decoration-neutral-500; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/cli/src/tui/theme.ts: -------------------------------------------------------------------------------- 1 | // Color palette matching apps/web (dark mode) 2 | export const colors = { 3 | // Backgrounds 4 | bg: '#0a0a0a', // neutral-950 5 | bgSubtle: '#171717', // neutral-900 6 | bgMuted: '#262626', // neutral-800 7 | 8 | // Text 9 | text: '#fafafa', // neutral-50 10 | textMuted: '#a3a3a3', // neutral-400 11 | textSubtle: '#737373', // neutral-500 12 | 13 | // Borders 14 | border: '#262626', // neutral-800 15 | borderSubtle: '#404040', // neutral-700 16 | 17 | // Accent (orange) 18 | accent: '#f97316', // orange-500 19 | accentDark: '#c2410c', // orange-700 20 | 21 | // Semantic 22 | success: '#22c55e', // green-500 23 | info: '#3b82f6', // blue-500 24 | error: '#ef4444' // red-500 25 | } as const; 26 | 27 | export type Colors = typeof colors; 28 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "rewriteRelativeImportExtensions": true, 5 | "allowJs": true, 6 | "checkJs": true, 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "resolveJsonModule": true, 10 | "skipLibCheck": true, 11 | "sourceMap": true, 12 | "strict": true, 13 | "moduleResolution": "bundler" 14 | } 15 | // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias 16 | // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files 17 | // 18 | // To make changes to top-level options such as include and exclude, we recommend extending 19 | // the generated config; see https://svelte.dev/docs/kit/configuration#typescript 20 | } 21 | -------------------------------------------------------------------------------- /packages/shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Environment setup & latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "Preserve", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedIndexedAccess": true, 22 | "noImplicitOverride": true, 23 | 24 | // Some stricter flags (disabled by default) 25 | "noUnusedLocals": false, 26 | "noUnusedParameters": false, 27 | "noPropertyAccessFromIndexSignature": false 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /apps/web/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 19 | %sveltekit.head% 20 | 21 | 22 |
%sveltekit.body%
23 | 24 | 25 | -------------------------------------------------------------------------------- /apps/cli/src/tui/clipboard.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'bun'; 2 | 3 | export async function copyToClipboard(text: string): Promise { 4 | const platform = process.platform; 5 | 6 | if (platform === 'darwin') { 7 | const proc = spawn(['pbcopy'], { stdin: 'pipe' }); 8 | proc.stdin.write(text); 9 | proc.stdin.end(); 10 | await proc.exited; 11 | } else if (platform === 'linux') { 12 | // Try xclip first, fall back to xsel 13 | try { 14 | const proc = spawn(['xclip', '-selection', 'clipboard'], { stdin: 'pipe' }); 15 | proc.stdin.write(text); 16 | proc.stdin.end(); 17 | await proc.exited; 18 | } catch { 19 | const proc = spawn(['xsel', '--clipboard', '--input'], { stdin: 'pipe' }); 20 | proc.stdin.write(text); 21 | proc.stdin.end(); 22 | await proc.exited; 23 | } 24 | } else if (platform === 'win32') { 25 | const proc = spawn(['clip'], { stdin: 'pipe' }); 26 | proc.stdin.write(text); 27 | proc.stdin.end(); 28 | await proc.exited; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/cli/src/tui/commands.ts: -------------------------------------------------------------------------------- 1 | import type { Command } from './types.ts'; 2 | 3 | export const COMMANDS: Command[] = [ 4 | { 5 | name: 'add', 6 | description: 'Add a new repo (wizard)', 7 | mode: 'add-repo' 8 | }, 9 | { 10 | name: 'remove', 11 | description: 'Remove a repo from config', 12 | mode: 'remove-repo' 13 | }, 14 | { 15 | name: 'model', 16 | description: 'Configure model & provider', 17 | mode: 'config-model' 18 | }, 19 | { 20 | name: 'chat', 21 | description: 'Start chat session (opens OpenCode)', 22 | mode: 'chat' 23 | }, 24 | { 25 | name: 'ask', 26 | description: 'Ask question, copy answer to clipboard', 27 | mode: 'ask' 28 | }, 29 | { 30 | name: 'clear', 31 | description: 'Clear chat history', 32 | mode: 'clear' 33 | } 34 | ]; 35 | 36 | export function filterCommands(query: string): Command[] { 37 | const lowerQuery = query.toLowerCase(); 38 | return COMMANDS.filter((cmd) => cmd.name.toLowerCase().startsWith(lowerQuery)); 39 | } 40 | -------------------------------------------------------------------------------- /apps/web/src/lib/CopyButton.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 30 | -------------------------------------------------------------------------------- /apps/web/README.md: -------------------------------------------------------------------------------- 1 | # sv 2 | 3 | Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). 4 | 5 | ## Creating a project 6 | 7 | If you're seeing this, you've probably already done this step. Congrats! 8 | 9 | ```sh 10 | # create a new project in the current directory 11 | npx sv create 12 | 13 | # create a new project in my-app 14 | npx sv create my-app 15 | ``` 16 | 17 | ## Developing 18 | 19 | Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 20 | 21 | ```sh 22 | npm run dev 23 | 24 | # or start the server and open the app in a new browser tab 25 | npm run dev -- --open 26 | ``` 27 | 28 | ## Building 29 | 30 | To create a production version of your app: 31 | 32 | ```sh 33 | npm run build 34 | ``` 35 | 36 | You can preview the production build with `npm run preview`. 37 | 38 | > To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. 39 | -------------------------------------------------------------------------------- /apps/cli/src/index.ts: -------------------------------------------------------------------------------- 1 | import { BunContext, BunRuntime } from '@effect/platform-bun'; 2 | import { Cause, Effect, Exit } from 'effect'; 3 | import { CliService } from './services/cli.ts'; 4 | 5 | // Check if no arguments provided (just "btca" or "bunx btca") 6 | const hasNoArgs = process.argv.length <= 2; 7 | 8 | if (hasNoArgs) { 9 | // Launch the TUI 10 | import('./tui/index.tsx').then(({ launchTui }) => launchTui()); 11 | } else { 12 | // Run the CLI with arguments 13 | Effect.gen(function* () { 14 | const cli = yield* CliService; 15 | yield* cli.run(process.argv); 16 | }).pipe( 17 | Effect.provide(CliService.Default), 18 | Effect.provide(BunContext.layer), 19 | BunRuntime.runMain({ 20 | teardown: (exit) => { 21 | // Force exit: opencode SDK's server.close() sends SIGTERM but doesn't 22 | // wait for child process termination, keeping Node's event loop alive 23 | const code = Exit.isFailure(exit) && !Cause.isInterruptedOnly(exit.cause) ? 1 : 0; 24 | process.exit(code); 25 | } 26 | }) 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /apps/web/src/lib/stores/ShikiStore.svelte.ts: -------------------------------------------------------------------------------- 1 | import { createContext, onDestroy, onMount } from 'svelte'; 2 | import { createHighlighter, type Highlighter } from 'shiki/bundle/web'; 3 | 4 | class ShikiStore { 5 | private highlighterPromise = createHighlighter({ 6 | themes: ['dark-plus', "light-plus"], 7 | langs: ['bash', 'json'] 8 | }); 9 | 10 | highlighter = $state(null); 11 | 12 | constructor() { 13 | onMount(async () => { 14 | this.highlighter = await this.highlighterPromise; 15 | }); 16 | onDestroy(() => { 17 | this.highlighter?.dispose(); 18 | }); 19 | } 20 | } 21 | 22 | const [internalGet, internalSet] = createContext(); 23 | 24 | export const getShikiStore = () => { 25 | const store = internalGet(); 26 | if (!store) { 27 | throw new Error('ShikiStore not found, did you call setShikiStore() in a parent component?'); 28 | } 29 | return store; 30 | }; 31 | 32 | export const setShikiStore = () => { 33 | const newStore = new ShikiStore(); 34 | return internalSet(newStore); 35 | }; 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2025 Ben Davis 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /apps/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src/**/*.ts", 4 | "scripts/**/*.ts" 5 | ], 6 | "exclude": [ 7 | "node_modules", 8 | "dist", 9 | "dist-check" 10 | ], 11 | "compilerOptions": { 12 | // Environment setup & latest features 13 | "lib": [ 14 | "ESNext", 15 | "DOM" 16 | ], 17 | "target": "ESNext", 18 | "module": "esnext", 19 | "moduleDetection": "force", 20 | "jsx": "react-jsx", 21 | "jsxImportSource": "@opentui/react", 22 | "allowJs": true, 23 | // Bundler mode 24 | "moduleResolution": "bundler", 25 | "allowImportingTsExtensions": true, 26 | "verbatimModuleSyntax": true, 27 | "noEmit": true, 28 | // Best practices 29 | "strict": true, 30 | "skipLibCheck": true, 31 | "noFallthroughCasesInSwitch": true, 32 | "noUncheckedIndexedAccess": true, 33 | "noImplicitOverride": true, 34 | // Some stricter flags (disabled by default) 35 | "noUnusedLocals": false, 36 | "noUnusedParameters": false, 37 | "noPropertyAccessFromIndexSignature": false 38 | } 39 | } -------------------------------------------------------------------------------- /apps/cli/src/lib/errors.ts: -------------------------------------------------------------------------------- 1 | import { TaggedError } from "effect/Data"; 2 | 3 | export class GeneralError extends TaggedError("GeneralError")<{ 4 | readonly message: string; 5 | readonly cause?: unknown; 6 | }> {} 7 | 8 | export class OcError extends TaggedError("OcError")<{ 9 | readonly message: string; 10 | readonly cause?: unknown; 11 | }> {} 12 | 13 | export class ConfigError extends TaggedError("ConfigError")<{ 14 | readonly message: string; 15 | readonly cause?: unknown; 16 | }> {} 17 | 18 | export class InvalidProviderError extends TaggedError("InvalidProviderError")<{ 19 | readonly providerId: string; 20 | readonly availableProviders: string[]; 21 | }> {} 22 | 23 | export class InvalidModelError extends TaggedError("InvalidModelError")<{ 24 | readonly providerId: string; 25 | readonly modelId: string; 26 | readonly availableModels: string[]; 27 | }> {} 28 | 29 | export class ProviderNotConnectedError extends TaggedError( 30 | "ProviderNotConnectedError" 31 | )<{ 32 | readonly providerId: string; 33 | readonly connectedProviders: string[]; 34 | }> {} 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Environment setup & latest features 4 | "lib": [ 5 | "ESNext", 6 | "DOM" 7 | ], 8 | "jsx": "react-jsx", 9 | "jsxImportSource": "@opentui/react", 10 | "target": "ESNext", 11 | "module": "Preserve", 12 | "moduleDetection": "force", 13 | "allowJs": true, 14 | // Bundler mode 15 | "moduleResolution": "bundler", 16 | "allowImportingTsExtensions": true, 17 | "verbatimModuleSyntax": true, 18 | "noEmit": true, 19 | // Best practices 20 | "strict": true, 21 | "skipLibCheck": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedIndexedAccess": true, 24 | "noImplicitOverride": true, 25 | "exactOptionalPropertyTypes": true, 26 | // Some stricter flags (disabled by default) 27 | "noUnusedLocals": false, 28 | "noUnusedParameters": false, 29 | "noPropertyAccessFromIndexSignature": false, 30 | // Effect Language Service 31 | "plugins": [ 32 | { 33 | "name": "@effect/language-service" 34 | } 35 | ] 36 | } 37 | } -------------------------------------------------------------------------------- /apps/cli/src/tui/components/RemoveRepoPrompt.tsx: -------------------------------------------------------------------------------- 1 | import type { Colors } from '../theme.ts'; 2 | 3 | interface RemoveRepoPromptProps { 4 | repoName: string; 5 | colors: Colors; 6 | } 7 | 8 | export function RemoveRepoPrompt({ repoName, colors }: RemoveRepoPromptProps) { 9 | return ( 10 | 24 | 25 | 26 | 27 | {`Are you sure you want to remove "`} 28 | {repoName} 29 | {`" from your configuration?`} 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@btca/repo", 3 | "author": "Ben Davis", 4 | "version": "0.1.4", 5 | "description": "CLI tool for asking questions about technologies using OpenCode", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/bmdavis419/better-context" 10 | }, 11 | "workspaces": [ 12 | "apps/*", 13 | "packages/*" 14 | ], 15 | "keywords": [ 16 | "cli", 17 | "ai", 18 | "docs" 19 | ], 20 | "engines": { 21 | "bun": ">=1.1.0" 22 | }, 23 | "packageManager": "bun@1.3.3", 24 | "scripts": { 25 | "clean": "rm -rf node_modules && rm -rf apps/web/node_modules && rm -rf apps/cli/node_modules && rm -rf packages/core/node_modules", 26 | "dev": "turbo run dev", 27 | "cli": "bun run apps/cli/src/index.ts", 28 | "cli:ask": "bun run apps/cli/src/index.ts ask", 29 | "cli:serve": "bun run apps/cli/src/index.ts serve", 30 | "cli:open": "bun run apps/cli/src/index.ts open", 31 | "build": "turbo run build", 32 | "check": "turbo run check" 33 | }, 34 | "devDependencies": { 35 | "@types/bun": "latest", 36 | "@typescript/native-preview": "^7.0.0-dev.20251215.1", 37 | "prettier": "^3.7.4", 38 | "turbo": "^2.6.3", 39 | "typescript": "^5.9.3" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/shared/src/index.ts: -------------------------------------------------------------------------------- 1 | type BlessedModel = { 2 | provider: string; 3 | model: string; 4 | description: string; 5 | isDefault: boolean; 6 | providerSetupUrl: string; 7 | }; 8 | 9 | export const BLESSED_MODELS: BlessedModel[] = [ 10 | { 11 | provider: 'opencode', 12 | model: 'btca-gemini-3-flash', 13 | description: 14 | 'Gemini 3 Flash with low reasoning (the special btca version is already configured for you in btca)', 15 | providerSetupUrl: 'https://opencode.ai/docs/providers/#opencode-zen', 16 | isDefault: false 17 | }, 18 | { 19 | provider: 'anthropic', 20 | model: 'claude-haiku-4-5', 21 | description: 'Claude Haiku 4.5, no reasoning', 22 | providerSetupUrl: 'https://opencode.ai/docs/providers/#anthropic', 23 | isDefault: false 24 | }, 25 | { 26 | provider: 'opencode', 27 | model: 'big-pickle', 28 | description: 'Big Pickle, surprisingly good (and free)', 29 | providerSetupUrl: 'https://opencode.ai/docs/providers/#opencode-zen', 30 | isDefault: true 31 | }, 32 | { 33 | provider: 'opencode', 34 | model: 'kimi-k2', 35 | description: 'Kimi K2, no reasoning', 36 | providerSetupUrl: 'https://opencode.ai/docs/providers/#opencode-zen', 37 | isDefault: false 38 | } 39 | ]; 40 | -------------------------------------------------------------------------------- /apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "private": true, 4 | "version": "0.0.1", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite dev", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "prepare": "svelte-kit sync || echo ''", 11 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 12 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 13 | "format": "prettier --write .", 14 | "lint": "prettier --check ." 15 | }, 16 | "devDependencies": { 17 | "@sveltejs/adapter-vercel": "^6.2.0", 18 | "@sveltejs/kit": "^2.48.5", 19 | "@sveltejs/vite-plugin-svelte": "^6.2.1", 20 | "prettier": "^3.6.2", 21 | "prettier-plugin-svelte": "^3.4.0", 22 | "prettier-plugin-tailwindcss": "^0.7.1", 23 | "@tailwindcss/typography": "^0.5.19", 24 | "@tailwindcss/vite": "^4.1.17", 25 | "shiki": "^3.19.0", 26 | "svelte": "^5.43.8", 27 | "svelte-check": "^4.3.4", 28 | "tailwindcss": "^4.1.17", 29 | "typescript": "^5.9.3", 30 | "vite": "^7.2.2", 31 | "vite-plugin-devtools-json": "^1.0.0" 32 | }, 33 | "dependencies": { 34 | "@btca/shared": "workspace:*", 35 | "@lucide/svelte": "^0.560.0", 36 | "@vercel/analytics": "^1.6.1" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/cli/src/sandbox/AddRepoPrompt.tsx: -------------------------------------------------------------------------------- 1 | interface Colors { 2 | bg: string; 3 | bgSubtle: string; 4 | bgMuted: string; 5 | text: string; 6 | textMuted: string; 7 | textSubtle: string; 8 | border: string; 9 | accent: string; 10 | info: string; 11 | } 12 | 13 | interface AddRepoPromptProps { 14 | value: string; 15 | onInput: (value: string) => void; 16 | onSubmit: () => void; 17 | colors: Colors; 18 | } 19 | 20 | export function AddRepoPrompt({ value, onInput, onSubmit, colors }: AddRepoPromptProps) { 21 | return ( 22 | 36 | 37 | 38 | 39 | 40 | 50 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /apps/cli/scripts/build-binaries.ts: -------------------------------------------------------------------------------- 1 | import { $ } from "bun"; 2 | import { FileSystem } from "@effect/platform"; 3 | import { BunContext, BunRuntime } from "@effect/platform-bun"; 4 | import { Effect } from "effect"; 5 | import packageJson from "../package.json"; 6 | 7 | const VERSION = packageJson.version; 8 | 9 | const targets = [ 10 | "bun-darwin-arm64", 11 | "bun-darwin-x64", 12 | "bun-linux-x64", 13 | "bun-linux-arm64", 14 | "bun-windows-x64", 15 | ] as const; 16 | 17 | const outputNames: Record<(typeof targets)[number], string> = { 18 | "bun-darwin-arm64": "btca-darwin-arm64", 19 | "bun-darwin-x64": "btca-darwin-x64", 20 | "bun-linux-x64": "btca-linux-x64", 21 | "bun-linux-arm64": "btca-linux-arm64", 22 | "bun-windows-x64": "btca-windows-x64.exe", 23 | }; 24 | 25 | const main = Effect.gen(function* () { 26 | const fs = yield* FileSystem.FileSystem; 27 | 28 | yield* fs.makeDirectory("dist", { recursive: true }); 29 | 30 | for (const target of targets) { 31 | const outfile = `dist/${outputNames[target]}`; 32 | console.log(`Building ${target} -> ${outfile} (v${VERSION})`); 33 | yield* Effect.promise( 34 | () => $`bun build src/index.ts --compile --target=${target} --outfile=${outfile} --define __VERSION__='"${VERSION}"'` 35 | ); 36 | } 37 | 38 | console.log("Done building all targets"); 39 | }); 40 | 41 | main.pipe(Effect.provide(BunContext.layer), BunRuntime.runMain); 42 | -------------------------------------------------------------------------------- /apps/cli/bin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { spawnSync } from "node:child_process"; 4 | import fs from "node:fs"; 5 | import path from "node:path"; 6 | import { fileURLToPath } from "node:url"; 7 | 8 | const PLATFORM_ARCH = `${process.platform}-${process.arch}`; 9 | 10 | const TARGET_MAP = { 11 | "darwin-arm64": "btca-darwin-arm64", 12 | "darwin-x64": "btca-darwin-x64", 13 | "linux-x64": "btca-linux-x64", 14 | "linux-arm64": "btca-linux-arm64", 15 | "win32-x64": "btca-windows-x64.exe", 16 | }; 17 | 18 | const binaryName = TARGET_MAP[PLATFORM_ARCH]; 19 | 20 | if (!binaryName) { 21 | console.error( 22 | `[btca] Unsupported platform: ${PLATFORM_ARCH}. ` + 23 | "Please open an issue with your OS/CPU details." 24 | ); 25 | process.exit(1); 26 | } 27 | 28 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 29 | const binPath = path.join(__dirname, "dist", binaryName); 30 | 31 | if (!fs.existsSync(binPath)) { 32 | console.error( 33 | `[btca] Prebuilt binary not found for ${PLATFORM_ARCH}. ` + 34 | "Try reinstalling, or open an issue if the problem persists." 35 | ); 36 | process.exit(1); 37 | } 38 | 39 | const result = spawnSync(binPath, process.argv.slice(2), { 40 | stdio: "inherit", 41 | }); 42 | 43 | if (result.error) { 44 | console.error(`[btca] Failed to start binary: ${result.error}`); 45 | process.exit(1); 46 | } 47 | 48 | process.exit(result.status ?? 1); 49 | -------------------------------------------------------------------------------- /apps/cli/src/lib/utils/git.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from 'effect'; 2 | import { ConfigError } from '../errors'; 3 | 4 | export const cloneRepo = (args: { 5 | repoDir: string; 6 | url: string; 7 | branch: string; 8 | quiet?: boolean; 9 | }) => 10 | Effect.tryPromise({ 11 | try: async () => { 12 | const { repoDir, url, branch, quiet } = args; 13 | const proc = Bun.spawn(['git', 'clone', '--branch', branch, url, repoDir], { 14 | stdout: quiet ? 'ignore' : 'inherit', 15 | stderr: quiet ? 'ignore' : 'inherit' 16 | }); 17 | const exitCode = await proc.exited; 18 | if (exitCode !== 0) { 19 | throw new Error(`git clone failed with exit code ${exitCode}`); 20 | } 21 | }, 22 | catch: (error) => new ConfigError({ message: 'Failed to clone repo', cause: error }) 23 | }); 24 | 25 | export const pullRepo = (args: { repoDir: string; branch: string; quiet?: boolean }) => 26 | Effect.tryPromise({ 27 | try: async () => { 28 | const { repoDir, branch, quiet } = args; 29 | const proc = Bun.spawn(['git', 'pull', 'origin', branch], { 30 | cwd: repoDir, 31 | stdout: quiet ? 'ignore' : 'inherit', 32 | stderr: quiet ? 'ignore' : 'inherit' 33 | }); 34 | const exitCode = await proc.exited; 35 | if (exitCode !== 0) { 36 | throw new Error(`git pull failed with exit code ${exitCode}`); 37 | } 38 | }, 39 | catch: (error) => new ConfigError({ message: 'Failed to pull repo', cause: error }) 40 | }); 41 | -------------------------------------------------------------------------------- /apps/cli/src/lib/utils/files.ts: -------------------------------------------------------------------------------- 1 | import { FileSystem, Path } from '@effect/platform'; 2 | import { Effect } from 'effect'; 3 | import { ConfigError } from '../errors.ts'; 4 | 5 | export const expandHome = (filePath: string): Effect.Effect => 6 | Effect.gen(function* () { 7 | const path = yield* Path.Path; 8 | if (filePath.startsWith('~/') || filePath.startsWith('~\\')) { 9 | const homeDir = Bun.env.HOME ?? Bun.env.USERPROFILE ?? ''; 10 | return path.join(homeDir, filePath.slice(2)); 11 | } 12 | return filePath; 13 | }); 14 | 15 | export const directoryExists = (dir: string) => 16 | Effect.gen(function* () { 17 | const fs = yield* FileSystem.FileSystem; 18 | const exists = yield* fs.exists(dir); 19 | if (!exists) return false; 20 | const stat = yield* fs.stat(dir); 21 | return stat.type === 'Directory'; 22 | }).pipe( 23 | Effect.catchAll((error) => 24 | Effect.fail( 25 | new ConfigError({ 26 | message: 'Failed to check directory', 27 | cause: error 28 | }) 29 | ) 30 | ) 31 | ); 32 | 33 | export const ensureDirectory = (dir: string) => 34 | Effect.gen(function* () { 35 | const fs = yield* FileSystem.FileSystem; 36 | const exists = yield* fs.exists(dir); 37 | if (!exists) { 38 | yield* fs.makeDirectory(dir, { recursive: true }); 39 | } 40 | }).pipe( 41 | Effect.catchAll((error) => 42 | Effect.fail( 43 | new ConfigError({ 44 | message: 'Failed to create directory', 45 | cause: error 46 | }) 47 | ) 48 | ) 49 | ); 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Better Context (`btca`) 2 | 3 | npm 4 | 5 | https://btca.dev 6 | 7 | `btca` is a CLI for asking questions about libraries/frameworks by cloning their repos locally and searching the source directly. 8 | 9 | Dev docs are in the `apps/cli` directory. 10 | 11 | ## Install 12 | 13 | ```bash 14 | bun add -g btca 15 | btca --help 16 | ``` 17 | 18 | ## Quick commands 19 | 20 | Ask a question: 21 | 22 | ```bash 23 | btca ask -t svelte -q "How do stores work in Svelte 5?" 24 | ``` 25 | 26 | Open the TUI: 27 | 28 | ```bash 29 | btca chat -t svelte 30 | ``` 31 | 32 | Run as a server: 33 | 34 | ```bash 35 | btca serve -p 8080 36 | ``` 37 | 38 | Then POST `/question` with: 39 | 40 | ```json 41 | { "tech": "svelte", "question": "how does the query remote function work in sveltekit?" } 42 | ``` 43 | 44 | Keep an OpenCode instance running: 45 | 46 | ```bash 47 | btca open 48 | ``` 49 | 50 | ## Config 51 | 52 | On first run, `btca` creates a default config at `~/.config/btca/btca.json`. That’s where the repo list + model/provider live. 53 | 54 | ## stuff I want to add 55 | 56 | - get the git repo for a package using bun: 57 | 58 | ```bash 59 | bun pm view react repository.url 60 | ``` 61 | 62 | - tui for working with btca (config, starting chat, server, etc.) 63 | - mcp server 64 | - multiple repos for a single btca instance 65 | - fetch all the branches to pick from when you add a repo in the tui 66 | - cleaner streamed output in the ask command 67 | -------------------------------------------------------------------------------- /apps/cli/src/sandbox/RepoSelector.tsx: -------------------------------------------------------------------------------- 1 | interface Colors { 2 | bg: string; 3 | bgSubtle: string; 4 | bgMuted: string; 5 | text: string; 6 | textMuted: string; 7 | textSubtle: string; 8 | border: string; 9 | accent: string; 10 | info: string; 11 | } 12 | 13 | interface RepoSelectorProps { 14 | repos: string[]; 15 | selectedIndex: number; 16 | currentRepo: number; 17 | colors: Colors; 18 | } 19 | 20 | export function RepoSelector({ repos, selectedIndex, currentRepo, colors }: RepoSelectorProps) { 21 | return ( 22 | 36 | 37 | 38 | 39 | {repos.map((repo, i) => { 40 | const isSelected = i === selectedIndex; 41 | const isCurrent = i === currentRepo; 42 | return ( 43 | 49 | 53 | {isCurrent && } 54 | 55 | ); 56 | })} 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # AGENTS.md 2 | 3 | 4 | 5 | ## Effect Solutions Usage 6 | 7 | The Effect Solutions CLI provides curated best practices and patterns for Effect TypeScript. Before working on Effect code, check if there's a relevant topic that covers your use case. 8 | 9 | - `effect-solutions list` - List all available topics 10 | - `effect-solutions show ` - Read one or more topics 11 | - `effect-solutions search ` - Search topics by keyword 12 | 13 | **Local Effect Source:** The Effect repository is cloned to `~/.local/share/effect-solutions/effect` for reference. Use this to explore APIs, find usage examples, and understand implementation details when the documentation isn't enough. 14 | 15 | 16 | 17 | ## Code Style 18 | 19 | - **Runtime**: Bun only. No Node.js, npm, pnpm, vite, dotenv. 20 | - **TypeScript**: Strict mode enabled. ESNext target. 21 | - **Effect**: Use `Effect.gen` for async code, `BunRuntime.runMain` for entry points. 22 | - **Imports**: External packages first, then local. Use `.ts` extensions for local imports. 23 | - **Bun APIs**: Prefer `Bun.file`, `Bun.serve`, `bun:sqlite`, `Bun.$` over Node equivalents. 24 | - **Testing**: Use `bun:test` with `import { test, expect } from "bun:test"`. 25 | 26 | ## Error Handling 27 | 28 | - Use Effect's error channel for typed errors. 29 | - Use `Effect.tryPromise` for async operations, `Effect.try` for sync. 30 | - Pipe errors through Effect combinators, don't throw. 31 | 32 | ## btca 33 | 34 | Trigger: user says "use btca" (for codebase/docs questions). 35 | 36 | Run: 37 | 38 | - btca ask -t -q "" 39 | 40 | Available : svelte, tailwindcss, opentui, runed 41 | -------------------------------------------------------------------------------- /apps/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "btca", 3 | "author": "Ben Davis", 4 | "version": "0.5.0", 5 | "description": "CLI tool for asking questions about technologies using OpenCode", 6 | "type": "module", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/bmdavis419/better-context" 11 | }, 12 | "keywords": [ 13 | "cli", 14 | "ai", 15 | "docs" 16 | ], 17 | "engines": { 18 | "bun": ">=1.1.0" 19 | }, 20 | "bin": { 21 | "btca": "./bin.js" 22 | }, 23 | "files": [ 24 | "bin.js", 25 | "dist" 26 | ], 27 | "publishConfig": { 28 | "access": "public" 29 | }, 30 | "scripts": { 31 | "build": "bun run scripts/build.ts", 32 | "build:targets": "bun run scripts/build-binaries.ts", 33 | "setup:platforms": "bun install --cpu=x64 --os=darwin && bun install --cpu=x64 --os=linux && bun install --cpu=arm64 --os=linux && bun install --cpu=x64 --os=win32", 34 | "build:artifacts": "bun run setup:platforms && bun run build && bun run build:targets", 35 | "prepublishOnly": "bun run build:artifacts", 36 | "check": "tsgo --noEmit", 37 | "prepare": "effect-language-service patch" 38 | }, 39 | "devDependencies": { 40 | "@btca/shared": "workspace:*", 41 | "@effect/cli": "^0.72.1", 42 | "@effect/language-service": "^0.62.1", 43 | "@effect/platform": "^0.93.5", 44 | "@effect/platform-bun": "^0.85.0", 45 | "@effect/printer": "^0.47.0", 46 | "@effect/printer-ansi": "^0.47.0", 47 | "@effect/typeclass": "^0.38.0", 48 | "@opencode-ai/sdk": "^1.0.119", 49 | "@opentui/core": "^0.1.60", 50 | "@opentui/react": "^0.1.60", 51 | "@types/bun": "latest", 52 | "@types/react": "^19.2.7", 53 | "effect": "^3.19.8", 54 | "react": "^19.2.3", 55 | "typescript": "^5.9.3" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /apps/cli/src/tui/components/CommandPalette.tsx: -------------------------------------------------------------------------------- 1 | import type { Command } from '../types.ts'; 2 | import type { Colors } from '../theme.ts'; 3 | 4 | interface CommandPaletteProps { 5 | commands: Command[]; 6 | selectedIndex: number; 7 | colors: Colors; 8 | } 9 | 10 | export function CommandPalette({ commands, selectedIndex, colors }: CommandPaletteProps) { 11 | if (commands.length === 0) { 12 | return ( 13 | 26 | 27 | 28 | ); 29 | } 30 | 31 | return ( 32 | 46 | 47 | 48 | {commands.map((cmd, i) => { 49 | const isSelected = i === selectedIndex; 50 | return ( 51 | 57 | 62 | 63 | 64 | ); 65 | })} 66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /apps/cli/src/lib/prompts.ts: -------------------------------------------------------------------------------- 1 | export const getDocsAgentPrompt = (args: { repoName: string; specialNotes?: string }) => ` 2 | You are an expert internal agent who's job is to answer coding questions and provide accurate and up to date info on ${ 3 | args.repoName 4 | } based on the codebase you have access to. It may be the source code, or it may be the documentation. You are running in the background, and the user cannot ask follow up questions. You must always answer the question based on the codebase you have access to. If the question is not related to the codebase you have access to, you must say so and that you are not able to answer the question. 5 | 6 | NEVER SEARCH THE WEB FOR INFORMATION. ALWAYS USE THE CODEBASE YOU HAVE ACCESS TO. 7 | 8 | You are running in the directory for: ${args.repoName}, when asked a question regarding ${args.repoName}, search the codebase to get an accurate answer. 9 | 10 | 11 | ${ 12 | args.specialNotes 13 | ? ` 14 | Special notes about the codebase you have access to: 15 | '${args.specialNotes}' 16 | ` 17 | : '' 18 | } 19 | 20 | Always search the codebase first before using the web to try to answer the question. 21 | 22 | When you are searching the codebase, be very careful that you do not read too much at once. Only read a small amount at a time as you're searching, avoid reading dozens of files at once... 23 | 24 | When responding: 25 | 26 | - Be extremely concise. Sacrifice grammar for the sake of concision. 27 | - When outputting code snippets, include comments that explain what each piece does 28 | - Always bias towards simple practical examples over complex theoretical explanations 29 | - Give your response in markdown format, make sure to have spacing between code blocks and other content 30 | - Avoid asking follow up questions, just answer the question 31 | `; 32 | -------------------------------------------------------------------------------- /apps/web/src/lib/stores/ThemeStore.svelte.ts: -------------------------------------------------------------------------------- 1 | import { browser } from '$app/environment'; 2 | import { createContext, onMount } from 'svelte'; 3 | 4 | export type Theme = 'light' | 'dark'; 5 | 6 | export const readStoredTheme = (): Theme | null => { 7 | if (!browser) return null; 8 | const v = window.localStorage.getItem('theme'); 9 | if (v === 'light' || v === 'dark') return v; 10 | return null; 11 | }; 12 | 13 | export const readPreferredTheme = (): Theme => { 14 | if (!browser) return 'dark'; 15 | const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false; 16 | return prefersDark ? 'dark' : 'light'; 17 | }; 18 | 19 | export const getInitialTheme = (): Theme => readStoredTheme() ?? readPreferredTheme(); 20 | 21 | export const setTheme = (theme: Theme): void => { 22 | if (!browser) return; 23 | document.documentElement.classList.toggle('dark', theme === 'dark'); 24 | if (!browser) return; 25 | window.localStorage.setItem('theme', theme); 26 | }; 27 | 28 | class ThemeStore { 29 | theme = $state('dark'); 30 | 31 | set = (theme: Theme) => { 32 | this.theme = theme; 33 | setTheme(theme); 34 | }; 35 | 36 | init = () => { 37 | this.set(getInitialTheme()); 38 | }; 39 | 40 | toggle = () => { 41 | this.set(this.theme === 'dark' ? 'light' : 'dark'); 42 | }; 43 | 44 | constructor() { 45 | onMount(() => this.init()); 46 | } 47 | } 48 | 49 | const [internalGet, internalSet] = createContext(); 50 | 51 | export const getThemeStore = () => { 52 | const store = internalGet(); 53 | if (!store) { 54 | throw new Error('ThemeStore not found, did you call setThemeStore() in a parent component?'); 55 | } 56 | return store; 57 | }; 58 | 59 | export const setThemeStore = () => { 60 | const newStore = new ThemeStore(); 61 | return internalSet(newStore); 62 | }; 63 | 64 | -------------------------------------------------------------------------------- /apps/cli/src/lib/utils/validation.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from "effect"; 2 | import type { OpencodeClient } from "@opencode-ai/sdk"; 3 | import { 4 | InvalidProviderError, 5 | InvalidModelError, 6 | ProviderNotConnectedError, 7 | } from "../errors"; 8 | 9 | export const validateProviderAndModel = ( 10 | client: OpencodeClient, 11 | providerId: string, 12 | modelId: string 13 | ) => 14 | Effect.gen(function* () { 15 | const response = yield* Effect.tryPromise(() => 16 | client.provider.list() 17 | ).pipe( 18 | Effect.option // Convert errors to None, success to Some 19 | ); 20 | 21 | // If we couldn't fetch providers, skip validation (fail open) 22 | if (response._tag === "None" || !response.value.data) { 23 | return; 24 | } 25 | 26 | const { all, connected } = response.value.data; 27 | 28 | // Check if provider exists 29 | const provider = all.find((p) => p.id === providerId); 30 | if (!provider) { 31 | return yield* Effect.fail( 32 | new InvalidProviderError({ 33 | providerId, 34 | availableProviders: all.map((p) => p.id), 35 | }) 36 | ); 37 | } 38 | 39 | // Check if provider is connected (has valid auth) 40 | if (!connected.includes(providerId)) { 41 | return yield* Effect.fail( 42 | new ProviderNotConnectedError({ 43 | providerId, 44 | connectedProviders: connected, 45 | }) 46 | ); 47 | } 48 | 49 | // Check if model exists for this provider 50 | const modelIds = Object.keys(provider.models); 51 | if (!modelIds.includes(modelId)) { 52 | return yield* Effect.fail( 53 | new InvalidModelError({ 54 | providerId, 55 | modelId, 56 | availableModels: modelIds, 57 | }) 58 | ); 59 | } 60 | }); 61 | -------------------------------------------------------------------------------- /apps/cli/src/sandbox/CommandPalette.tsx: -------------------------------------------------------------------------------- 1 | import type { Command } from './commands.ts'; 2 | 3 | interface Colors { 4 | bg: string; 5 | bgSubtle: string; 6 | bgMuted: string; 7 | text: string; 8 | textMuted: string; 9 | textSubtle: string; 10 | border: string; 11 | accent: string; 12 | } 13 | 14 | interface CommandPaletteProps { 15 | commands: Command[]; 16 | selectedIndex: number; 17 | colors: Colors; 18 | } 19 | 20 | export function CommandPalette({ commands, selectedIndex, colors }: CommandPaletteProps) { 21 | if (commands.length === 0) { 22 | return ( 23 | 36 | 37 | 38 | ); 39 | } 40 | 41 | return ( 42 | 56 | 57 | 58 | {commands.map((cmd, i) => { 59 | const isSelected = i === selectedIndex; 60 | return ( 61 | 67 | 72 | 73 | 74 | ); 75 | })} 76 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /apps/cli/src/tui/components/RepoSelector.tsx: -------------------------------------------------------------------------------- 1 | import type { Repo } from '../types.ts'; 2 | import type { Colors } from '../theme.ts'; 3 | 4 | interface RepoSelectorProps { 5 | repos: Repo[]; 6 | selectedIndex: number; 7 | currentRepo: number; 8 | searchQuery: string; 9 | colors: Colors; 10 | } 11 | 12 | export function RepoSelector({ 13 | repos, 14 | selectedIndex, 15 | currentRepo, 16 | searchQuery, 17 | colors 18 | }: RepoSelectorProps) { 19 | return ( 20 | 34 | 35 | 36 | {searchQuery && ( 37 | 38 | 39 | 40 | 41 | )} 42 | 43 | {repos.length === 0 ? ( 44 | 45 | ) : ( 46 | repos.map((repo, i) => { 47 | const isSelected = i === selectedIndex; 48 | // Note: currentRepo is an index into the ORIGINAL repos array, not filtered 49 | // We can't directly compare, so we skip this indicator when filtering 50 | return ( 51 | 57 | 61 | 62 | ); 63 | }) 64 | )} 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /apps/cli/src/tui/services.ts: -------------------------------------------------------------------------------- 1 | import { BunContext } from '@effect/platform-bun'; 2 | import { Effect, Layer, ManagedRuntime, Stream } from 'effect'; 3 | import { ConfigService } from '../services/config.ts'; 4 | import { OcService, type OcEvent } from '../services/oc.ts'; 5 | import type { Repo } from './types.ts'; 6 | 7 | const ServicesLayer = Layer.mergeAll(OcService.Default, ConfigService.Default).pipe( 8 | Layer.provideMerge(BunContext.layer) 9 | ); 10 | 11 | const runtime = ManagedRuntime.make(ServicesLayer); 12 | 13 | export const services = { 14 | getRepos: (): Promise => 15 | runtime.runPromise( 16 | Effect.gen(function* () { 17 | const config = yield* ConfigService; 18 | const repos = yield* config.getRepos(); 19 | // Convert readonly to mutable 20 | return repos.map((r) => ({ ...r })); 21 | }) 22 | ), 23 | 24 | addRepo: (repo: Repo): Promise => 25 | runtime.runPromise( 26 | Effect.gen(function* () { 27 | const config = yield* ConfigService; 28 | const added = yield* config.addRepo(repo); 29 | return { ...added }; 30 | }) 31 | ), 32 | 33 | removeRepo: (name: string): Promise => 34 | runtime.runPromise( 35 | Effect.gen(function* () { 36 | const config = yield* ConfigService; 37 | yield* config.removeRepo(name); 38 | }) 39 | ), 40 | 41 | getModel: (): Promise<{ provider: string; model: string }> => 42 | runtime.runPromise( 43 | Effect.gen(function* () { 44 | const config = yield* ConfigService; 45 | return yield* config.getModel(); 46 | }) 47 | ), 48 | 49 | updateModel: (provider: string, model: string): Promise<{ provider: string; model: string }> => 50 | runtime.runPromise( 51 | Effect.gen(function* () { 52 | const config = yield* ConfigService; 53 | return yield* config.updateModel({ provider, model }); 54 | }) 55 | ), 56 | 57 | // OC operations 58 | spawnTui: (tech: string): Promise => 59 | runtime.runPromise( 60 | Effect.gen(function* () { 61 | const oc = yield* OcService; 62 | yield* oc.spawnTui({ tech }); 63 | }) 64 | ), 65 | 66 | askQuestion: (tech: string, question: string, onEvent: (event: OcEvent) => void): Promise => 67 | runtime.runPromise( 68 | Effect.gen(function* () { 69 | const oc = yield* OcService; 70 | const stream = yield* oc.askQuestion({ 71 | question, 72 | tech, 73 | suppressLogs: true 74 | }); 75 | 76 | yield* Stream.runForEach(stream, (event) => Effect.sync(() => onEvent(event))); 77 | }) 78 | ) 79 | }; 80 | 81 | export type Services = typeof services; 82 | -------------------------------------------------------------------------------- /apps/cli/src/tui/components/ModelConfig.tsx: -------------------------------------------------------------------------------- 1 | import type { Colors } from '../theme.ts'; 2 | 3 | export type ModelConfigStep = 'provider' | 'model' | 'confirm'; 4 | 5 | interface ModelConfigProps { 6 | step: ModelConfigStep; 7 | values: { 8 | provider: string; 9 | model: string; 10 | }; 11 | currentInput: string; 12 | onInput: (value: string) => void; 13 | onSubmit: () => void; 14 | colors: Colors; 15 | } 16 | 17 | const STEP_INFO: Record = { 18 | provider: { 19 | title: 'Step 1/2: Provider', 20 | hint: 'Enter provider ID (e.g., "opencode", "anthropic", "openai")', 21 | placeholder: 'opencode' 22 | }, 23 | model: { 24 | title: 'Step 2/2: Model', 25 | hint: 'Enter model ID (e.g., "big-pickle", "claude-sonnet-4-20250514")', 26 | placeholder: 'big-pickle' 27 | }, 28 | confirm: { 29 | title: 'Confirm', 30 | hint: 'Press Enter to save, Esc to cancel', 31 | placeholder: '' 32 | } 33 | }; 34 | 35 | export function ModelConfig({ 36 | step, 37 | values, 38 | currentInput, 39 | onInput, 40 | onSubmit, 41 | colors 42 | }: ModelConfigProps) { 43 | const info = STEP_INFO[step]; 44 | 45 | return ( 46 | 60 | 61 | 62 | 63 | 64 | {step === 'confirm' ? ( 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | ) : ( 78 | 79 | 89 | 90 | )} 91 | 92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /apps/cli/README.md: -------------------------------------------------------------------------------- 1 | # btca 2 | 3 | A CLI tool for asking questions about technologies using their source code repositories. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | bun install 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```bash 14 | bun run src/index.ts 15 | ``` 16 | 17 | Or after building: 18 | 19 | ```bash 20 | btca 21 | ``` 22 | 23 | ## Commands 24 | 25 | ### `btca` 26 | 27 | Show version information. 28 | 29 | ### `btca ask` 30 | 31 | Ask a question about a technology. 32 | 33 | ```bash 34 | btca ask -t -q 35 | btca ask --tech svelte --question "How do I create a reactive store?" 36 | ``` 37 | 38 | Options: 39 | 40 | - `-t, --tech` - The technology/repo to query 41 | - `-q, --question` - The question to ask 42 | 43 | ### `btca chat` 44 | 45 | Start an interactive TUI chat session. 46 | 47 | ```bash 48 | btca chat -t 49 | btca chat --tech nextjs 50 | ``` 51 | 52 | Options: 53 | 54 | - `-t, --tech` - The technology/repo to chat about 55 | 56 | ### `btca serve` 57 | 58 | Start an HTTP server to answer questions via API. 59 | 60 | ```bash 61 | btca serve 62 | btca serve -p 3000 63 | ``` 64 | 65 | Options: 66 | 67 | - `-p, --port` - Port to listen on (default: 8080) 68 | 69 | Endpoint: 70 | 71 | - `POST /question` - Send `{ "tech": "svelte", "question": "..." }` to get answers 72 | 73 | ### `btca open` 74 | 75 | Hold an OpenCode instance in the background for faster subsequent queries. 76 | 77 | ```bash 78 | btca open 79 | ``` 80 | 81 | ### `btca config` 82 | 83 | Manage CLI configuration. Shows the config file path when run without subcommands. 84 | 85 | ```bash 86 | btca config 87 | ``` 88 | 89 | #### `btca config model` 90 | 91 | View or set the model and provider. 92 | 93 | ```bash 94 | # View current model/provider 95 | btca config model 96 | 97 | # Set model and provider 98 | btca config model -p -m 99 | btca config model --provider anthropic --model claude-3-opus 100 | ``` 101 | 102 | Options: 103 | 104 | - `-p, --provider` - The provider to use 105 | - `-m, --model` - The model to use 106 | 107 | Both options must be specified together when updating. 108 | 109 | #### `btca config repos list` 110 | 111 | List all configured repositories. 112 | 113 | ```bash 114 | btca config repos list 115 | ``` 116 | 117 | #### `btca config repos add` 118 | 119 | Add a new repository to the configuration. 120 | 121 | ```bash 122 | btca config repos add -n -u [-b ] [--notes ] 123 | btca config repos add --name react --url https://github.com/facebook/react --branch main 124 | ``` 125 | 126 | Options: 127 | 128 | - `-n, --name` - Unique name for the repo (required) 129 | - `-u, --url` - Git repository URL (required) 130 | - `-b, --branch` - Branch to use (default: "main") 131 | - `--notes` - Special instructions for the AI when using this repo 132 | 133 | ## Configuration 134 | 135 | Configuration is stored at `~/.config/btca/btca.json`. The config file includes: 136 | 137 | - `promptsDirectory` - Directory for system prompts 138 | - `reposDirectory` - Directory where repos are cloned 139 | - `port` - Default server port 140 | - `maxInstances` - Maximum concurrent OpenCode instances 141 | - `repos` - Array of configured repositories 142 | - `model` - AI model to use 143 | - `provider` - AI provider to use 144 | -------------------------------------------------------------------------------- /apps/web/src/routes/og/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |
9 | 13 | 14 |
15 |
16 |
17 |
20 | 21 |
22 |
23 |
26 | Better Context 27 |
28 |
btca CLI
29 |
30 |
31 | 32 |
33 | github.com/bmdavis419/better-context 34 |
35 |
36 | 37 |
38 |
41 | AI Powered Docs Search 42 |
43 | 44 |

47 | Ask a question. Search the actual codebase. Get a real answer. 48 |

49 | 50 |

51 | Local-first CLI that clones repos and searches source directly. 52 |

53 |
54 | 55 |
56 |
57 |
58 | 59 | Subcommands: ask · chat · serve · open 60 |
61 |
62 | 63 | Built with Bun + Effect + SvelteKit 64 |
65 |
66 | 67 |
70 |
bun add -g btca
73 | btca ask -t svelte -q "How do stores work?"
75 |
76 |
77 |
78 |
79 |
80 | -------------------------------------------------------------------------------- /apps/cli/src/tui/components/AddRepoWizard.tsx: -------------------------------------------------------------------------------- 1 | import type { Colors } from '../theme.ts'; 2 | import type { Repo } from '../types.ts'; 3 | 4 | export type WizardStep = 'name' | 'url' | 'branch' | 'notes' | 'confirm'; 5 | 6 | interface AddRepoWizardProps { 7 | step: WizardStep; 8 | values: { 9 | name: string; 10 | url: string; 11 | branch: string; 12 | notes: string; 13 | }; 14 | currentInput: string; 15 | onInput: (value: string) => void; 16 | onSubmit: () => void; 17 | colors: Colors; 18 | } 19 | 20 | const STEP_INFO: Record = { 21 | name: { 22 | title: 'Step 1/4: Repository Name', 23 | hint: 'Enter a unique name for this repo (e.g., "react", "svelte-docs")', 24 | placeholder: 'repo-name' 25 | }, 26 | url: { 27 | title: 'Step 2/4: Repository URL', 28 | hint: 'Enter the GitHub repository URL', 29 | placeholder: 'https://github.com/owner/repo' 30 | }, 31 | branch: { 32 | title: 'Step 3/4: Branch', 33 | hint: 'Enter the branch to clone (press Enter for "main")', 34 | placeholder: 'main' 35 | }, 36 | notes: { 37 | title: 'Step 4/4: Special Notes (Optional)', 38 | hint: 'Any special notes for the AI? Press Enter to skip', 39 | placeholder: 'e.g., "This is the docs website, not the library"' 40 | }, 41 | confirm: { 42 | title: 'Confirm', 43 | hint: 'Press Enter to add repo, Esc to cancel', 44 | placeholder: '' 45 | } 46 | }; 47 | 48 | export function AddRepoWizard({ 49 | step, 50 | values, 51 | currentInput, 52 | onInput, 53 | onSubmit, 54 | colors 55 | }: AddRepoWizardProps) { 56 | const info = STEP_INFO[step]; 57 | 58 | return ( 59 | 73 | 74 | 75 | 76 | 77 | {step === 'confirm' ? ( 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | {values.notes && ( 92 | 93 | 94 | 95 | 96 | )} 97 | 98 | 99 | 100 | ) : ( 101 | 102 | 112 | 113 | )} 114 | 115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /apps/web/src/routes/models/+page.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
16 |
17 |
18 | Configuration 22 | 23 |
24 | 25 |

28 | Models 29 |

30 | 31 |

34 | Any model that works with OpenCode works with btca. Under the hood btca uses the OpenCode SDK, 35 | which will read your local config. 36 |

37 |
38 | 39 |
40 | {#each BLESSED_MODELS as model} 41 |
44 |
45 |
46 | {model.model} 50 | {model.provider} 54 | {#if model.isDefault} 55 | Default 59 | {/if} 60 |
61 |

62 | {model.description} 63 |

64 |
67 |
68 |
69 | {#if shikiStore.highlighter} 70 | {@html shikiStore.highlighter.codeToHtml( 71 | getCommand(model.provider, model.model), 72 | { 73 | theme: shikiTheme, 74 | lang: 'bash', 75 | rootStyle: 'background-color: transparent; padding: 0; margin: 0;' 76 | } 77 | )} 78 | {:else} 79 |
{getCommand(model.provider, model.model)}
83 | {/if} 84 |
85 | 89 |
90 |
91 | 97 | Provider setup instructions → 98 | 99 |
100 |
101 | {/each} 102 |
103 |
104 | -------------------------------------------------------------------------------- /apps/web/src/routes/commands/+page.svelte: -------------------------------------------------------------------------------- 1 | 68 | 69 |
70 |
71 |
72 | Reference 76 | 77 |
78 | 79 |

82 | Commands 83 |

84 | 85 |

88 | All available btca commands with descriptions and examples. 91 |

92 |
93 | 94 |
95 | {#each commands as cmd} 96 |
99 |
100 |
101 | {cmd.name} 105 |
106 |

107 | {cmd.description} 108 |

109 |
112 |
113 |
114 | {#if shikiStore.highlighter} 115 | {@html shikiStore.highlighter.codeToHtml(cmd.example, { 116 | theme: shikiTheme, 117 | lang: 'bash', 118 | rootStyle: 'background-color: transparent; padding: 0; margin: 0;' 119 | })} 120 | {:else} 121 |
{cmd.example}
125 | {/if} 126 |
127 | 128 |
129 |
130 |
131 |
132 | {/each} 133 |
134 |
135 | -------------------------------------------------------------------------------- /apps/web/src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | 28 | 29 | Better Context 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
48 | 52 | 53 |
56 |
57 | 58 |
59 |
62 | 63 |
64 |
65 |
66 | The Better Context App 67 |
68 |
CLI: btca
69 |
70 |
71 |
72 | 73 |
74 | 80 | 86 | 92 | 102 | 103 | 116 |
117 |
118 |
119 | 120 |
121 | {#if fullBleed} 122 | {@render children()} 123 | {:else} 124 |
125 | {@render children()} 126 |
127 | {/if} 128 |
129 | 130 |
131 |
134 |
Built with Bun + Effect + SvelteKit
135 |
136 | GitHub 139 | Install 140 |
141 |
142 |
143 |
144 | -------------------------------------------------------------------------------- /apps/cli/src/services/oc.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createOpencode, 3 | OpencodeClient, 4 | type Event, 5 | type Config as OpenCodeConfig 6 | } from '@opencode-ai/sdk'; 7 | import { spawn } from 'bun'; 8 | import { Deferred, Duration, Effect, Stream } from 'effect'; 9 | import { ConfigService } from './config.ts'; 10 | import { OcError } from '../lib/errors.ts'; 11 | import { validateProviderAndModel } from '../lib/utils/validation.ts'; 12 | 13 | const spawnOpencodeTui = async (args: { 14 | config: OpenCodeConfig; 15 | repoDir: string; 16 | rawConfig: { provider: string; model: string }; 17 | }) => { 18 | const proc = spawn(['opencode', `--model=${args.rawConfig.provider}/${args.rawConfig.model}`], { 19 | stdin: 'inherit', 20 | stdout: 'inherit', 21 | stderr: 'inherit', 22 | cwd: args.repoDir, 23 | env: { 24 | ...process.env, 25 | OPENCODE_CONFIG_CONTENT: JSON.stringify(args.config) 26 | } 27 | }); 28 | 29 | await proc.exited; 30 | }; 31 | 32 | export type { Event as OcEvent }; 33 | 34 | const ocService = Effect.gen(function* () { 35 | const config = yield* ConfigService; 36 | 37 | const rawConfig = yield* config.rawConfig(); 38 | 39 | const getOpencodeInstance = ({ tech }: { tech: string }) => 40 | Effect.gen(function* () { 41 | let portOffset = 0; 42 | const maxInstances = 5; 43 | const { ocConfig, repoDir } = yield* config.getOpenCodeConfig({ repoName: tech }); 44 | 45 | yield* Effect.sync(() => process.chdir(repoDir)); 46 | 47 | while (portOffset < maxInstances) { 48 | const result = yield* Effect.tryPromise(() => 49 | createOpencode({ 50 | port: 3420 + portOffset, 51 | config: ocConfig 52 | }) 53 | ).pipe( 54 | Effect.catchAll((err) => { 55 | if (err.cause instanceof Error && err.cause.stack?.includes('port')) { 56 | portOffset++; 57 | return Effect.succeed(null); 58 | } 59 | return Effect.fail( 60 | new OcError({ 61 | message: 'FAILED TO CREATE OPENCODE CLIENT', 62 | cause: err 63 | }) 64 | ); 65 | }) 66 | ); 67 | if (result !== null) { 68 | return result; 69 | } 70 | } 71 | return yield* Effect.fail( 72 | new OcError({ 73 | message: 'FAILED TO CREATE OPENCODE CLIENT - all ports exhausted', 74 | cause: null 75 | }) 76 | ); 77 | }); 78 | 79 | const streamSessionEvents = (args: { sessionID: string; client: OpencodeClient }) => 80 | Effect.gen(function* () { 81 | const { sessionID, client } = args; 82 | 83 | const events = yield* Effect.tryPromise({ 84 | try: () => client.event.subscribe(), 85 | catch: (err) => 86 | new OcError({ 87 | message: 'Failed to subscribe to events', 88 | cause: err 89 | }) 90 | }); 91 | 92 | return Stream.fromAsyncIterable( 93 | events.stream, 94 | (e) => new OcError({ message: 'Event stream error', cause: e }) 95 | ).pipe( 96 | Stream.filter((event) => { 97 | const props = event.properties; 98 | if (!('sessionID' in props)) return true; 99 | return props.sessionID === sessionID; 100 | }), 101 | Stream.takeUntil( 102 | (event) => event.type === 'session.idle' && event.properties.sessionID === sessionID 103 | ) 104 | ); 105 | }); 106 | 107 | const firePrompt = (args: { 108 | sessionID: string; 109 | text: string; 110 | errorDeferred: Deferred.Deferred; 111 | client: OpencodeClient; 112 | }) => 113 | Effect.promise(() => 114 | args.client.session.prompt({ 115 | path: { id: args.sessionID }, 116 | body: { 117 | agent: 'docs', 118 | model: { 119 | providerID: rawConfig.provider, 120 | modelID: rawConfig.model 121 | }, 122 | parts: [{ type: 'text', text: args.text }] 123 | } 124 | }) 125 | ).pipe( 126 | Effect.catchAll((err) => 127 | Deferred.fail(args.errorDeferred, new OcError({ message: String(err), cause: err })) 128 | ) 129 | ); 130 | 131 | const streamPrompt = (args: { 132 | sessionID: string; 133 | prompt: string; 134 | client: OpencodeClient; 135 | cleanup: () => void; 136 | }) => 137 | Effect.gen(function* () { 138 | const { sessionID, prompt, client } = args; 139 | 140 | const eventStream = yield* streamSessionEvents({ sessionID, client }); 141 | 142 | const errorDeferred = yield* Deferred.make(); 143 | 144 | yield* firePrompt({ 145 | sessionID, 146 | text: prompt, 147 | errorDeferred, 148 | client 149 | }).pipe(Effect.forkDaemon); 150 | 151 | // Transform stream to fail on session.error, race with prompt error 152 | return eventStream.pipe( 153 | Stream.mapEffect((event) => 154 | Effect.gen(function* () { 155 | if (event.type === 'session.error') { 156 | const props = event.properties as { error?: { name?: string } }; 157 | return yield* Effect.fail( 158 | new OcError({ 159 | message: props.error?.name ?? 'Unknown session error', 160 | cause: props.error 161 | }) 162 | ); 163 | } 164 | return event; 165 | }) 166 | ), 167 | Stream.ensuring(Effect.sync(() => args.cleanup())), 168 | Stream.interruptWhen(Deferred.await(errorDeferred)) 169 | ); 170 | }); 171 | 172 | return { 173 | spawnTui: (args: { tech: string }) => 174 | Effect.gen(function* () { 175 | const { tech } = args; 176 | 177 | yield* config.cloneOrUpdateOneRepoLocally(tech, { suppressLogs: false }); 178 | 179 | const { ocConfig, repoDir } = yield* config.getOpenCodeConfig({ 180 | repoName: tech 181 | }); 182 | 183 | yield* Effect.tryPromise({ 184 | try: () => spawnOpencodeTui({ config: ocConfig, repoDir, rawConfig }), 185 | catch: (err) => new OcError({ message: 'TUI exited with error', cause: err }) 186 | }); 187 | }), 188 | holdOpenInstanceInBg: () => 189 | Effect.gen(function* () { 190 | const { client, server } = yield* getOpencodeInstance({ 191 | tech: 'svelte' 192 | }); 193 | 194 | yield* Effect.log(`OPENCODE SERVER IS UP AT ${server.url}`); 195 | 196 | yield* Effect.sleep(Duration.days(1)); 197 | }), 198 | askQuestion: (args: { question: string; tech: string; suppressLogs: boolean }) => 199 | Effect.gen(function* () { 200 | const { question, tech, suppressLogs } = args; 201 | 202 | yield* config.cloneOrUpdateOneRepoLocally(tech, { suppressLogs }); 203 | 204 | const { client, server } = yield* getOpencodeInstance({ tech }); 205 | 206 | yield* validateProviderAndModel(client, rawConfig.provider, rawConfig.model); 207 | 208 | const session = yield* Effect.promise(() => client.session.create()); 209 | 210 | if (session.error) { 211 | return yield* Effect.fail( 212 | new OcError({ 213 | message: 'FAILED TO START OPENCODE SESSION', 214 | cause: session.error 215 | }) 216 | ); 217 | } 218 | 219 | const sessionID = session.data.id; 220 | 221 | return yield* streamPrompt({ 222 | sessionID, 223 | prompt: question, 224 | client, 225 | cleanup: () => { 226 | server.close(); 227 | } 228 | }); 229 | }) 230 | }; 231 | }); 232 | 233 | export class OcService extends Effect.Service()('OcService', { 234 | effect: ocService, 235 | dependencies: [ConfigService.Default] 236 | }) {} 237 | -------------------------------------------------------------------------------- /apps/cli/src/sandbox/App.tsx: -------------------------------------------------------------------------------- 1 | import { useKeyboard } from '@opentui/react'; 2 | import { useState, useMemo } from 'react'; 3 | import { filterCommands, type Command } from './commands.ts'; 4 | import { CommandPalette } from './CommandPalette.tsx'; 5 | import { AddRepoPrompt } from './AddRepoPrompt.tsx'; 6 | import { RepoSelector } from './RepoSelector.tsx'; 7 | 8 | // Color palette matching apps/web (dark mode) 9 | const colors = { 10 | // Backgrounds 11 | bg: '#0a0a0a', // neutral-950 12 | bgSubtle: '#171717', // neutral-900 13 | bgMuted: '#262626', // neutral-800 14 | 15 | // Text 16 | text: '#fafafa', // neutral-50 17 | textMuted: '#a3a3a3', // neutral-400 18 | textSubtle: '#737373', // neutral-500 19 | 20 | // Borders 21 | border: '#262626', // neutral-800 22 | borderSubtle: '#404040', // neutral-700 23 | 24 | // Accent (orange) 25 | accent: '#f97316', // orange-500 26 | accentDark: '#c2410c', // orange-700 27 | 28 | // Semantic 29 | success: '#22c55e', // green-500 30 | info: '#3b82f6' // blue-500 31 | }; 32 | 33 | const MODELS = { 34 | sonnet: 'anthropic/claude-sonnet-4-20250514', 35 | opus: 'anthropic/claude-opus-4-20250514', 36 | haiku: 'anthropic/claude-haiku-3-20240307' 37 | } as const; 38 | 39 | type ModelKey = keyof typeof MODELS; 40 | type Mode = 'chat' | 'command-palette' | 'add-repo' | 'select-repo'; 41 | 42 | interface Message { 43 | role: 'user' | 'assistant' | 'system'; 44 | content: string; 45 | } 46 | 47 | export function App() { 48 | // Core state 49 | const [repos, setRepos] = useState(['opentui', 'effect', 'svelte', 'nextjs', 'react', 'clerk']); 50 | const [selectedRepo, setSelectedRepo] = useState(0); 51 | const [messages, setMessages] = useState([ 52 | { role: 'user', content: 'How do I create a TUI with opentui?' }, 53 | { 54 | role: 'assistant', 55 | content: 'Use createCliRenderer() to start, then add components to the root.' 56 | } 57 | ]); 58 | 59 | // Mode and input state 60 | const [mode, setMode] = useState('chat'); 61 | const [inputValue, setInputValue] = useState(''); 62 | const [commandIndex, setCommandIndex] = useState(0); 63 | const [addRepoValue, setAddRepoValue] = useState(''); 64 | const [repoSelectorIndex, setRepoSelectorIndex] = useState(0); 65 | 66 | // Derived state 67 | const showCommandPalette = mode === 'chat' && inputValue.startsWith('/'); 68 | const commandQuery = inputValue.slice(1); // Remove the '/' 69 | const filteredCommands = useMemo(() => filterCommands(commandQuery), [commandQuery]); 70 | 71 | // Reset command index when filtered commands change 72 | const clampedCommandIndex = Math.min(commandIndex, Math.max(0, filteredCommands.length - 1)); 73 | 74 | const handleInputChange = (value: string) => { 75 | setInputValue(value); 76 | // Reset command index when input changes 77 | setCommandIndex(0); 78 | }; 79 | 80 | const executeCommand = (command: Command) => { 81 | setInputValue(''); 82 | setCommandIndex(0); 83 | 84 | if (command.mode === 'add-repo') { 85 | setMode('add-repo'); 86 | setAddRepoValue(''); 87 | } else if (command.mode === 'select-repo') { 88 | setMode('select-repo'); 89 | setRepoSelectorIndex(selectedRepo); 90 | } 91 | }; 92 | 93 | const handleChatSubmit = () => { 94 | const value = inputValue.trim(); 95 | if (!value) return; 96 | 97 | // If showing command palette and pressing enter, execute selected command 98 | if (showCommandPalette && filteredCommands.length > 0) { 99 | const command = filteredCommands[clampedCommandIndex]; 100 | if (command) { 101 | executeCommand(command); 102 | return; 103 | } 104 | } 105 | 106 | // Regular message 107 | setMessages((prev) => [...prev, { role: 'user', content: value }]); 108 | setInputValue(''); 109 | }; 110 | 111 | const handleAddRepo = () => { 112 | const value = addRepoValue.trim(); 113 | if (!value) return; 114 | 115 | setRepos((prev) => [...prev, value]); 116 | setMessages((prev) => [...prev, { role: 'system', content: `Added repo: ${value}` }]); 117 | setAddRepoValue(''); 118 | setMode('chat'); 119 | }; 120 | 121 | const handleSelectRepo = () => { 122 | setSelectedRepo(repoSelectorIndex); 123 | setMessages([]); // Clear chat when switching repo 124 | setMode('chat'); 125 | }; 126 | 127 | const cancelMode = () => { 128 | setMode('chat'); 129 | setInputValue(''); 130 | setAddRepoValue(''); 131 | }; 132 | 133 | useKeyboard((key) => { 134 | // Escape always cancels current mode 135 | if (key.name === 'escape') { 136 | key.preventDefault(); 137 | if (mode !== 'chat') { 138 | cancelMode(); 139 | } else if (showCommandPalette) { 140 | setInputValue(''); 141 | } 142 | return; 143 | } 144 | 145 | // Mode-specific keyboard handling 146 | if (mode === 'chat' && showCommandPalette) { 147 | // Command palette navigation 148 | if (key.name === 'up') { 149 | key.preventDefault(); 150 | setCommandIndex((prev) => (prev === 0 ? filteredCommands.length - 1 : prev - 1)); 151 | } else if (key.name === 'down') { 152 | key.preventDefault(); 153 | setCommandIndex((prev) => (prev === filteredCommands.length - 1 ? 0 : prev + 1)); 154 | } 155 | } else if (mode === 'select-repo') { 156 | // Repo selector navigation 157 | if (key.name === 'up') { 158 | key.preventDefault(); 159 | setRepoSelectorIndex((prev) => (prev === 0 ? repos.length - 1 : prev - 1)); 160 | } else if (key.name === 'down') { 161 | key.preventDefault(); 162 | setRepoSelectorIndex((prev) => (prev === repos.length - 1 ? 0 : prev + 1)); 163 | } 164 | } 165 | 166 | if (key.name === 'return' && mode === 'select-repo') { 167 | key.preventDefault(); 168 | handleSelectRepo(); 169 | } 170 | }); 171 | 172 | return ( 173 | 181 | {/* Header */} 182 | 196 | 197 | {'◆'} 198 | {' btca'} 199 | {' - Better Context AI'} 200 | 201 | 202 | 203 | 204 | {/* Content area */} 205 | 212 | {/* Sidebar */} 213 | 223 | 224 | 225 | {repos.map((repo, i) => ( 226 | 231 | ))} 232 | 233 | 234 | {/* Chat area */} 235 | 245 | 246 | 247 | {messages.map((msg, i) => ( 248 | 249 | 258 | {msg.role === 'user' ? 'You ' : msg.role === 'system' ? 'SYS ' : 'AI '} 259 | 260 | 261 | 262 | ))} 263 | 264 | 265 | 266 | {/* Overlays - positioned relative to the input area */} 267 | {showCommandPalette && ( 268 | 273 | )} 274 | 275 | {mode === 'add-repo' && ( 276 | 282 | )} 283 | 284 | {mode === 'select-repo' && ( 285 | 291 | )} 292 | 293 | {/* Input area */} 294 | 304 | 317 | 318 | 319 | {/* Status bar */} 320 | 331 | 343 | 344 | 345 | ); 346 | } 347 | -------------------------------------------------------------------------------- /apps/web/src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 |
20 |
21 |
22 | AI Powered Docs Search 26 | 29 |
30 | 31 |

34 | Up to Date Info About any Technology 35 |

36 | 37 |

40 | btca 41 | is a CLI for asking questions about libraries/frameworks by cloning their repos locally and searching 42 | the source directly 43 |

44 | 45 | 61 |
62 | 63 |
64 |

65 | Install 66 |

67 |

68 | Install globally with Bun, then run btca --help. 71 |

72 |
75 |
76 | {#if shikiStore.highlighter} 77 | {@html shikiStore.highlighter.codeToHtml(INSTALL_CMD, { 78 | theme: shikiTheme, 79 | lang: 'bash', 80 | rootStyle: 'background-color: transparent; padding: 0; margin: 0; height: 100%;' 81 | })} 82 | {:else} 83 |
{INSTALL_CMD}
87 | {/if} 88 |
89 | 90 |
91 |
92 | 93 |
94 |

95 | Quick commands 96 |

97 |

98 | The CLI currently ships these subcommands: 99 | ask, 100 | chat, 101 | serve, 102 | open. 103 |

104 | 105 |
106 |
109 |
110 | Ask a question 111 |
112 |
115 |
116 |
117 | {#if shikiStore.highlighter} 118 | {@html shikiStore.highlighter.codeToHtml(ASK_CMD, { 119 | theme: shikiTheme, 120 | lang: 'bash', 121 | rootStyle: 'background-color: transparent; padding: 0; margin: 0;' 122 | })} 123 | {:else} 124 |
{ASK_CMD}
128 | {/if} 129 |
130 | 131 |
132 |
133 |
134 |
137 |
Open the TUI
138 |
141 |
142 |
143 | {#if shikiStore.highlighter} 144 | {@html shikiStore.highlighter.codeToHtml(CHAT_CMD, { 145 | theme: shikiTheme, 146 | lang: 'bash', 147 | rootStyle: 'background-color: transparent; padding: 0; margin: 0;' 148 | })} 149 | {:else} 150 |
{CHAT_CMD}
154 | {/if} 155 |
156 | 157 |
158 |
159 |
160 |
163 |
164 | Run as a server 165 |
166 |
169 |
170 |
171 | {#if shikiStore.highlighter} 172 | {@html shikiStore.highlighter.codeToHtml(SERVE_CMD, { 173 | theme: shikiTheme, 174 | lang: 'bash', 175 | rootStyle: 'background-color: transparent; padding: 0; margin: 0;' 176 | })} 177 | {:else} 178 |
{SERVE_CMD}
182 | {/if} 183 |
184 | 185 |
186 |
187 |
188 | POST /question 191 | with 192 | {"tech","question"}. 195 |
196 |
197 |
200 |
201 | Keep an OpenCode instance running 202 |
203 |
206 |
207 |
208 | {#if shikiStore.highlighter} 209 | {@html shikiStore.highlighter.codeToHtml(OPEN_CMD, { 210 | theme: shikiTheme, 211 | lang: 'bash', 212 | rootStyle: 'background-color: transparent; padding: 0; margin: 0;' 213 | })} 214 | {:else} 215 |
{OPEN_CMD}
219 | {/if} 220 |
221 | 222 |
223 |
224 |
225 |
226 |
227 | 228 |
229 |

230 | Config 231 |

232 |

233 | On first run, btca 236 | creates a default config at 237 | ~/.config/btca/btca.json. That’s where repo list + model/provider live. 240 |

241 |
242 |
243 | -------------------------------------------------------------------------------- /apps/cli/src/services/config.ts: -------------------------------------------------------------------------------- 1 | import type { Config as OpenCodeConfig, ProviderConfig } from '@opencode-ai/sdk'; 2 | import { FileSystem, Path } from '@effect/platform'; 3 | import { Effect, Schema } from 'effect'; 4 | import { getDocsAgentPrompt } from '../lib/prompts.ts'; 5 | import { ConfigError } from '../lib/errors.ts'; 6 | import { cloneRepo, pullRepo } from '../lib/utils/git.ts'; 7 | import { directoryExists, expandHome } from '../lib/utils/files.ts'; 8 | import { BLESSED_MODELS } from '@btca/shared'; 9 | 10 | const CONFIG_DIRECTORY = '~/.config/btca'; 11 | const CONFIG_FILENAME = 'btca.json'; 12 | 13 | const repoSchema = Schema.Struct({ 14 | name: Schema.String, 15 | url: Schema.String, 16 | branch: Schema.String, 17 | specialNotes: Schema.String.pipe(Schema.optional) 18 | }); 19 | 20 | const configSchema = Schema.Struct({ 21 | reposDirectory: Schema.String, 22 | port: Schema.Number, 23 | maxInstances: Schema.Number, 24 | repos: Schema.Array(repoSchema), 25 | model: Schema.String, 26 | provider: Schema.String 27 | }); 28 | 29 | type Config = typeof configSchema.Type; 30 | type Repo = typeof repoSchema.Type; 31 | 32 | const DEFAULT_CONFIG: Config = { 33 | reposDirectory: '~/.local/share/btca/repos', 34 | port: 3420, 35 | maxInstances: 5, 36 | repos: [ 37 | { 38 | name: 'svelte', 39 | url: 'https://github.com/sveltejs/svelte.dev', 40 | branch: 'main', 41 | specialNotes: 42 | 'This is the svelte docs website repo, not the actual svelte repo. Use the docs to answer questions about svelte.' 43 | }, 44 | { 45 | name: 'tailwindcss', 46 | url: 'https://github.com/tailwindlabs/tailwindcss.com', 47 | branch: 'main', 48 | specialNotes: 49 | 'This is the tailwindcss docs website repo, not the actual tailwindcss repo. Use the docs to answer questions about tailwindcss.' 50 | }, 51 | { 52 | name: 'nextjs', 53 | url: 'https://github.com/vercel/next.js', 54 | branch: 'canary' 55 | } 56 | ], 57 | model: 'big-pickle', 58 | provider: 'opencode' 59 | }; 60 | 61 | const collapseHome = (path: string): string => { 62 | const home = process.env.HOME ?? process.env.USERPROFILE ?? ''; 63 | if (home && path.startsWith(home)) { 64 | return '~' + path.slice(home.length); 65 | } 66 | return path; 67 | }; 68 | 69 | const writeConfig = (config: Config) => 70 | Effect.gen(function* () { 71 | const path = yield* Path.Path; 72 | const fs = yield* FileSystem.FileSystem; 73 | 74 | const configDir = yield* expandHome(CONFIG_DIRECTORY); 75 | const configPath = path.join(configDir, CONFIG_FILENAME); 76 | 77 | // Collapse expanded paths back to tilde for storage 78 | const configToWrite: Config = { 79 | ...config, 80 | reposDirectory: collapseHome(config.reposDirectory) 81 | }; 82 | 83 | yield* fs.writeFileString(configPath, JSON.stringify(configToWrite, null, 2)).pipe( 84 | Effect.catchAll((error) => 85 | Effect.fail( 86 | new ConfigError({ 87 | message: 'Failed to write config', 88 | cause: error 89 | }) 90 | ) 91 | ) 92 | ); 93 | 94 | return configToWrite; 95 | }); 96 | 97 | // models setup the way I like them, the ones I would recommend for use are: 98 | // gemini 3 flash with low reasoning, haiku 4.5 with no reasoning, big pickle (surprisingly good), and kimi K2 99 | const BTCA_PRESET_MODELS: Record = { 100 | opencode: { 101 | models: { 102 | 'btca-gemini-3-flash': { 103 | id: 'gemini-3-flash', 104 | options: { 105 | generationConfig: { 106 | thinkingConfig: { 107 | thinkingLevel: 'low' 108 | } 109 | } 110 | } 111 | } 112 | } 113 | } 114 | }; 115 | 116 | const OPENCODE_CONFIG = (args: { 117 | repoName: string; 118 | specialNotes?: string; 119 | }): Effect.Effect => 120 | Effect.gen(function* () { 121 | const path = yield* Path.Path; 122 | return { 123 | provider: BTCA_PRESET_MODELS, 124 | agent: { 125 | build: { 126 | disable: true 127 | }, 128 | explore: { 129 | disable: true 130 | }, 131 | general: { 132 | disable: true 133 | }, 134 | plan: { 135 | disable: true 136 | }, 137 | docs: { 138 | prompt: getDocsAgentPrompt({ 139 | repoName: args.repoName, 140 | specialNotes: args.specialNotes 141 | }), 142 | disable: false, 143 | description: 'Get answers about libraries and frameworks by searching their source code', 144 | permission: { 145 | webfetch: 'deny', 146 | edit: 'deny', 147 | bash: 'deny', 148 | external_directory: 'deny', 149 | doom_loop: 'deny' 150 | }, 151 | mode: 'primary', 152 | tools: { 153 | write: false, 154 | bash: false, 155 | delete: false, 156 | read: true, 157 | grep: true, 158 | glob: true, 159 | list: true, 160 | path: false, 161 | todowrite: false, 162 | todoread: false, 163 | websearch: false 164 | } 165 | } 166 | } 167 | }; 168 | }); 169 | 170 | const onStartLoadConfig = Effect.gen(function* () { 171 | const path = yield* Path.Path; 172 | const fs = yield* FileSystem.FileSystem; 173 | 174 | const configDir = yield* expandHome(CONFIG_DIRECTORY); 175 | const configPath = path.join(configDir, CONFIG_FILENAME); 176 | 177 | const exists = yield* fs.exists(configPath); 178 | 179 | if (!exists) { 180 | yield* Effect.log(`Config file not found at ${configPath}, creating default config...`); 181 | // Ensure directory exists 182 | yield* fs 183 | .makeDirectory(configDir, { recursive: true }) 184 | .pipe(Effect.catchAll(() => Effect.void)); 185 | yield* fs.writeFileString(configPath, JSON.stringify(DEFAULT_CONFIG, null, 2)).pipe( 186 | Effect.catchAll((error) => 187 | Effect.fail( 188 | new ConfigError({ 189 | message: 'Failed to create default config', 190 | cause: error 191 | }) 192 | ) 193 | ) 194 | ); 195 | yield* Effect.log(`Default config created at ${configPath}`); 196 | const reposDir = yield* expandHome(DEFAULT_CONFIG.reposDirectory); 197 | const config = { 198 | ...DEFAULT_CONFIG, 199 | reposDirectory: reposDir 200 | } satisfies Config; 201 | return { 202 | config, 203 | configPath 204 | }; 205 | } else { 206 | const content = yield* fs.readFileString(configPath).pipe( 207 | Effect.catchAll((error) => 208 | Effect.fail( 209 | new ConfigError({ 210 | message: 'Failed to load config', 211 | cause: error 212 | }) 213 | ) 214 | ) 215 | ); 216 | const parsed = JSON.parse(content); 217 | return yield* Effect.succeed(parsed).pipe( 218 | Effect.flatMap(Schema.decode(configSchema)), 219 | Effect.flatMap((loadedConfig) => 220 | Effect.gen(function* () { 221 | const reposDir = yield* expandHome(loadedConfig.reposDirectory); 222 | const config = { 223 | ...loadedConfig, 224 | reposDirectory: reposDir 225 | } satisfies Config; 226 | return { 227 | config, 228 | configPath 229 | }; 230 | }) 231 | ) 232 | ); 233 | } 234 | }); 235 | 236 | const configService = Effect.gen(function* () { 237 | const path = yield* Path.Path; 238 | const loadedConfig = yield* onStartLoadConfig; 239 | 240 | let { config, configPath } = loadedConfig; 241 | 242 | const getRepo = ({ repoName, config }: { repoName: string; config: Config }) => 243 | Effect.gen(function* () { 244 | const repo = config.repos.find((repo) => repo.name === repoName); 245 | if (!repo) { 246 | return yield* Effect.fail(new ConfigError({ message: 'Repo not found' })); 247 | } 248 | return repo; 249 | }); 250 | 251 | return { 252 | getConfigPath: () => Effect.succeed(configPath), 253 | cloneOrUpdateOneRepoLocally: (repoName: string, options: { suppressLogs: boolean }) => 254 | Effect.gen(function* () { 255 | const repo = yield* getRepo({ repoName, config }); 256 | const repoDir = path.join(config.reposDirectory, repo.name); 257 | const branch = repo.branch ?? 'main'; 258 | const suppressLogs = options.suppressLogs; 259 | 260 | const exists = yield* directoryExists(repoDir); 261 | if (exists) { 262 | if (!suppressLogs) yield* Effect.log(`Pulling latest changes for ${repo.name}...`); 263 | yield* pullRepo({ repoDir, branch, quiet: suppressLogs }); 264 | } else { 265 | if (!suppressLogs) yield* Effect.log(`Cloning ${repo.name}...`); 266 | yield* cloneRepo({ repoDir, url: repo.url, branch, quiet: suppressLogs }); 267 | } 268 | if (!suppressLogs) yield* Effect.log(`Done with ${repo.name}`); 269 | return repo; 270 | }), 271 | getOpenCodeConfig: (args: { repoName: string }) => 272 | Effect.gen(function* () { 273 | const repo = yield* getRepo({ repoName: args.repoName, config }).pipe( 274 | Effect.catchTag('ConfigError', () => Effect.succeed(undefined)) 275 | ); 276 | const ocConfig = yield* OPENCODE_CONFIG({ 277 | repoName: args.repoName, 278 | specialNotes: repo?.specialNotes 279 | }); 280 | 281 | const repoDir = path.join(config.reposDirectory, args.repoName); 282 | 283 | return { 284 | ocConfig, 285 | repoDir 286 | }; 287 | }), 288 | rawConfig: () => Effect.succeed(config), 289 | getRepos: () => Effect.succeed(config.repos), 290 | getModel: () => Effect.succeed({ provider: config.provider, model: config.model }), 291 | updateModel: (args: { provider: string; model: string }) => 292 | Effect.gen(function* () { 293 | config = { ...config, provider: args.provider, model: args.model }; 294 | yield* writeConfig(config); 295 | return { provider: config.provider, model: config.model }; 296 | }), 297 | addRepo: (repo: Repo) => 298 | Effect.gen(function* () { 299 | const existing = config.repos.find((r) => r.name === repo.name); 300 | if (existing) { 301 | return yield* Effect.fail( 302 | new ConfigError({ message: `Repo "${repo.name}" already exists` }) 303 | ); 304 | } 305 | config = { ...config, repos: [...config.repos, repo] }; 306 | yield* writeConfig(config); 307 | return repo; 308 | }), 309 | getBlessedModels: () => Effect.succeed(BLESSED_MODELS), 310 | removeRepo: (repoName: string) => 311 | Effect.gen(function* () { 312 | const existing = config.repos.find((r) => r.name === repoName); 313 | if (!existing) { 314 | return yield* Effect.fail(new ConfigError({ message: `Repo "${repoName}" not found` })); 315 | } 316 | config = { ...config, repos: config.repos.filter((r) => r.name !== repoName) }; 317 | yield* writeConfig(config); 318 | }), 319 | getReposDirectory: () => Effect.succeed(config.reposDirectory) 320 | }; 321 | }); 322 | 323 | export class ConfigService extends Effect.Service()('ConfigService', { 324 | effect: configService 325 | }) {} 326 | -------------------------------------------------------------------------------- /apps/cli/src/services/cli.ts: -------------------------------------------------------------------------------- 1 | import { Command, Options } from '@effect/cli'; 2 | import { 3 | FileSystem, 4 | HttpRouter, 5 | HttpServer, 6 | HttpServerRequest, 7 | HttpServerResponse 8 | } from '@effect/platform'; 9 | import { BunHttpServer } from '@effect/platform-bun'; 10 | import { Effect, Layer, Schema, Stream } from 'effect'; 11 | import * as readline from 'readline'; 12 | import { OcService, type OcEvent } from './oc.ts'; 13 | import { ConfigService } from './config.ts'; 14 | 15 | declare const __VERSION__: string; 16 | const VERSION: string = typeof __VERSION__ !== 'undefined' ? __VERSION__ : '0.0.0-dev'; 17 | 18 | const programLayer = Layer.mergeAll(OcService.Default, ConfigService.Default); 19 | 20 | // === Ask Subcommand === 21 | const questionOption = Options.text('question').pipe(Options.withAlias('q')); 22 | const techOption = Options.text('tech').pipe(Options.withAlias('t')); 23 | 24 | const askCommand = Command.make( 25 | 'ask', 26 | { question: questionOption, tech: techOption }, 27 | ({ question, tech }) => 28 | Effect.gen(function* () { 29 | const oc = yield* OcService; 30 | const eventStream = yield* oc.askQuestion({ tech, question, suppressLogs: false }); 31 | 32 | let currentMessageId: string | null = null; 33 | 34 | yield* eventStream.pipe( 35 | Stream.runForEach((event) => 36 | Effect.sync(() => { 37 | switch (event.type) { 38 | case 'message.part.updated': 39 | if (event.properties.part.type === 'text') { 40 | if (currentMessageId === event.properties.part.messageID) { 41 | process.stdout.write(event.properties.delta ?? ''); 42 | } else { 43 | currentMessageId = event.properties.part.messageID; 44 | process.stdout.write('\n\n' + event.properties.part.text); 45 | } 46 | } 47 | break; 48 | default: 49 | break; 50 | } 51 | }) 52 | ) 53 | ); 54 | 55 | console.log('\n'); 56 | }).pipe( 57 | Effect.catchTags({ 58 | InvalidProviderError: (e) => 59 | Effect.sync(() => { 60 | console.error(`Error: Unknown provider "${e.providerId}"`); 61 | console.error(`Available providers: ${e.availableProviders.join(', ')}`); 62 | process.exit(1); 63 | }), 64 | InvalidModelError: (e) => 65 | Effect.sync(() => { 66 | console.error(`Error: Unknown model "${e.modelId}" for provider "${e.providerId}"`); 67 | console.error(`Available models: ${e.availableModels.join(', ')}`); 68 | process.exit(1); 69 | }), 70 | ProviderNotConnectedError: (e) => 71 | Effect.sync(() => { 72 | console.error(`Error: Provider "${e.providerId}" is not connected`); 73 | console.error(`Connected providers: ${e.connectedProviders.join(', ')}`); 74 | console.error(`Run "opencode auth" to configure provider credentials.`); 75 | process.exit(1); 76 | }) 77 | }), 78 | Effect.provide(programLayer) 79 | ) 80 | ); 81 | 82 | // === Open Subcommand === 83 | const openCommand = Command.make('open', {}, () => 84 | Effect.gen(function* () { 85 | const oc = yield* OcService; 86 | yield* oc.holdOpenInstanceInBg(); 87 | }).pipe(Effect.provide(programLayer)) 88 | ); 89 | 90 | // === Chat Subcommand === 91 | const chatTechOption = Options.text('tech').pipe(Options.withAlias('t')); 92 | 93 | const chatCommand = Command.make('chat', { tech: chatTechOption }, ({ tech }) => 94 | Effect.gen(function* () { 95 | const oc = yield* OcService; 96 | yield* oc.spawnTui({ tech }); 97 | }).pipe(Effect.provide(programLayer)) 98 | ); 99 | 100 | // === Serve Subcommand === 101 | const QuestionRequest = Schema.Struct({ 102 | tech: Schema.String, 103 | question: Schema.String 104 | }); 105 | 106 | const portOption = Options.integer('port').pipe(Options.withAlias('p'), Options.withDefault(8080)); 107 | 108 | const serveCommand = Command.make('serve', { port: portOption }, ({ port }) => 109 | Effect.gen(function* () { 110 | const router = HttpRouter.empty.pipe( 111 | HttpRouter.post( 112 | '/question', 113 | Effect.gen(function* () { 114 | const body = yield* HttpServerRequest.schemaBodyJson(QuestionRequest); 115 | const oc = yield* OcService; 116 | 117 | const eventStream = yield* oc.askQuestion({ 118 | tech: body.tech, 119 | question: body.question, 120 | suppressLogs: false 121 | }); 122 | 123 | const chunks: string[] = []; 124 | let currentMessageId: string | null = null; 125 | yield* eventStream.pipe( 126 | Stream.runForEach((event) => 127 | Effect.sync(() => { 128 | switch (event.type) { 129 | case 'message.part.updated': 130 | if (event.properties.part.type === 'text') { 131 | if (currentMessageId === event.properties.part.messageID) { 132 | chunks[chunks.length - 1] += event.properties.delta ?? ''; 133 | } else { 134 | currentMessageId = event.properties.part.messageID; 135 | chunks.push(event.properties.part.text ?? ''); 136 | } 137 | } 138 | break; 139 | default: 140 | break; 141 | } 142 | }) 143 | ) 144 | ); 145 | 146 | return yield* HttpServerResponse.json({ answer: chunks.join('') }); 147 | }) 148 | ) 149 | ); 150 | 151 | const ServerLive = BunHttpServer.layer({ port }); 152 | 153 | const HttpLive = router.pipe( 154 | HttpServer.serve(), 155 | HttpServer.withLogAddress, 156 | Layer.provide(ServerLive) 157 | ); 158 | 159 | return yield* Layer.launch(HttpLive); 160 | }).pipe(Effect.scoped, Effect.provide(programLayer)) 161 | ); 162 | 163 | // === Config Subcommands === 164 | 165 | // config model - view or set model/provider 166 | const providerOption = Options.text('provider').pipe(Options.withAlias('p'), Options.optional); 167 | const modelOption = Options.text('model').pipe(Options.withAlias('m'), Options.optional); 168 | 169 | const configModelCommand = Command.make( 170 | 'model', 171 | { provider: providerOption, model: modelOption }, 172 | ({ provider, model }) => 173 | Effect.gen(function* () { 174 | const config = yield* ConfigService; 175 | 176 | // If both options provided, update the config 177 | if (provider._tag === 'Some' && model._tag === 'Some') { 178 | const result = yield* config.updateModel({ 179 | provider: provider.value, 180 | model: model.value 181 | }); 182 | console.log(`Updated model configuration:`); 183 | console.log(` Provider: ${result.provider}`); 184 | console.log(` Model: ${result.model}`); 185 | } else if (provider._tag === 'Some' || model._tag === 'Some') { 186 | // If only one is provided, show an error 187 | console.error('Error: Both --provider and --model must be specified together'); 188 | process.exit(1); 189 | } else { 190 | // No options, show current values 191 | const current = yield* config.getModel(); 192 | console.log(`Current model configuration:`); 193 | console.log(` Provider: ${current.provider}`); 194 | console.log(` Model: ${current.model}`); 195 | } 196 | }).pipe(Effect.provide(programLayer)) 197 | ); 198 | 199 | // config repos list - list all repos 200 | const configReposListCommand = Command.make('list', {}, () => 201 | Effect.gen(function* () { 202 | const config = yield* ConfigService; 203 | const repos = yield* config.getRepos(); 204 | 205 | if (repos.length === 0) { 206 | console.log('No repos configured.'); 207 | return; 208 | } 209 | 210 | console.log('Configured repos:\n'); 211 | for (const repo of repos) { 212 | console.log(` ${repo.name}`); 213 | console.log(` URL: ${repo.url}`); 214 | console.log(` Branch: ${repo.branch}`); 215 | if (repo.specialNotes) { 216 | console.log(` Notes: ${repo.specialNotes}`); 217 | } 218 | console.log(); 219 | } 220 | }).pipe(Effect.provide(programLayer)) 221 | ); 222 | 223 | // config repos add - add a new repo 224 | const repoNameOption = Options.text('name').pipe(Options.withAlias('n')); 225 | const repoUrlOption = Options.text('url').pipe(Options.withAlias('u')); 226 | const repoBranchOption = Options.text('branch').pipe( 227 | Options.withAlias('b'), 228 | Options.withDefault('main') 229 | ); 230 | const repoNotesOption = Options.text('notes').pipe(Options.optional); 231 | 232 | const configReposAddCommand = Command.make( 233 | 'add', 234 | { 235 | name: repoNameOption.pipe(Options.optional), 236 | url: repoUrlOption.pipe(Options.optional), 237 | branch: repoBranchOption, 238 | notes: repoNotesOption 239 | }, 240 | ({ name, url, branch, notes }) => 241 | Effect.gen(function* () { 242 | const config = yield* ConfigService; 243 | 244 | let repoName: string; 245 | if (name._tag === 'Some') { 246 | repoName = name.value; 247 | } else { 248 | repoName = yield* askText('Enter repo name: '); 249 | } 250 | 251 | if (!repoName) { 252 | console.log('No repo name provided.'); 253 | return; 254 | } 255 | 256 | let repoUrl: string; 257 | if (url._tag === 'Some') { 258 | repoUrl = url.value; 259 | } else { 260 | repoUrl = yield* askText('Enter repo URL: '); 261 | } 262 | 263 | if (!repoUrl) { 264 | console.log('No repo URL provided.'); 265 | return; 266 | } 267 | 268 | const repo = { 269 | name: repoName, 270 | url: repoUrl, 271 | branch, 272 | ...(notes._tag === 'Some' ? { specialNotes: notes.value } : {}) 273 | }; 274 | 275 | yield* config.addRepo(repo); 276 | console.log(`Added repo "${repoName}":`); 277 | console.log(` URL: ${repoUrl}`); 278 | console.log(` Branch: ${branch}`); 279 | if (notes._tag === 'Some') { 280 | console.log(` Notes: ${notes.value}`); 281 | } 282 | }).pipe( 283 | Effect.catchTag('ConfigError', (e) => 284 | Effect.sync(() => { 285 | console.error(`Error: ${e.message}`); 286 | process.exit(1); 287 | }) 288 | ), 289 | Effect.provide(programLayer) 290 | ) 291 | ); 292 | 293 | // config repos clear - clear all downloaded repos 294 | const askConfirmation = (question: string): Effect.Effect => 295 | Effect.async((resume) => { 296 | const rl = readline.createInterface({ 297 | input: process.stdin, 298 | output: process.stdout 299 | }); 300 | 301 | rl.question(question, (answer) => { 302 | rl.close(); 303 | const normalized = answer.toLowerCase().trim(); 304 | resume(Effect.succeed(normalized === 'y' || normalized === 'yes')); 305 | }); 306 | }); 307 | 308 | const askText = (question: string): Effect.Effect => 309 | Effect.async((resume) => { 310 | const rl = readline.createInterface({ 311 | input: process.stdin, 312 | output: process.stdout 313 | }); 314 | 315 | rl.question(question, (answer) => { 316 | rl.close(); 317 | resume(Effect.succeed(answer.trim())); 318 | }); 319 | }); 320 | 321 | const configReposRemoveCommand = Command.make( 322 | 'remove', 323 | { name: repoNameOption.pipe(Options.optional) }, 324 | ({ name }) => 325 | Effect.gen(function* () { 326 | const config = yield* ConfigService; 327 | 328 | let repoName: string; 329 | if (name._tag === 'Some') { 330 | repoName = name.value; 331 | } else { 332 | repoName = yield* askText('Enter repo name to remove: '); 333 | } 334 | 335 | if (!repoName) { 336 | console.log('No repo name provided.'); 337 | return; 338 | } 339 | 340 | // Check if repo exists 341 | const repos = yield* config.getRepos(); 342 | const exists = repos.find((r) => r.name === repoName); 343 | if (!exists) { 344 | console.error(`Error: Repo "${repoName}" not found.`); 345 | process.exit(1); 346 | } 347 | 348 | const confirmed = yield* askConfirmation( 349 | `Are you sure you want to remove repo "${repoName}" from config? (y/N): ` 350 | ); 351 | 352 | if (!confirmed) { 353 | console.log('Aborted.'); 354 | return; 355 | } 356 | 357 | yield* config.removeRepo(repoName); 358 | console.log(`Removed repo "${repoName}".`); 359 | }).pipe( 360 | Effect.catchTag('ConfigError', (e) => 361 | Effect.sync(() => { 362 | console.error(`Error: ${e.message}`); 363 | process.exit(1); 364 | }) 365 | ), 366 | Effect.provide(programLayer) 367 | ) 368 | ); 369 | 370 | const configReposClearCommand = Command.make('clear', {}, () => 371 | Effect.gen(function* () { 372 | const config = yield* ConfigService; 373 | const fs = yield* FileSystem.FileSystem; 374 | 375 | const reposDir = yield* config.getReposDirectory(); 376 | 377 | // Check if repos directory exists 378 | const exists = yield* fs.exists(reposDir); 379 | if (!exists) { 380 | console.log('Repos directory does not exist. Nothing to clear.'); 381 | return; 382 | } 383 | 384 | // List all directories in the repos directory 385 | const entries = yield* fs.readDirectory(reposDir); 386 | const repoPaths: string[] = []; 387 | 388 | for (const entry of entries) { 389 | const fullPath = `${reposDir}/${entry}`; 390 | const stat = yield* fs.stat(fullPath); 391 | if (stat.type === 'Directory') { 392 | repoPaths.push(fullPath); 393 | } 394 | } 395 | 396 | if (repoPaths.length === 0) { 397 | console.log('No repos found in the repos directory. Nothing to clear.'); 398 | return; 399 | } 400 | 401 | console.log('The following repos will be deleted:\n'); 402 | for (const repoPath of repoPaths) { 403 | console.log(` ${repoPath}`); 404 | } 405 | console.log(); 406 | 407 | const confirmed = yield* askConfirmation( 408 | 'Are you sure you want to delete these repos? (y/N): ' 409 | ); 410 | 411 | if (!confirmed) { 412 | console.log('Aborted.'); 413 | return; 414 | } 415 | 416 | for (const repoPath of repoPaths) { 417 | yield* fs.remove(repoPath, { recursive: true }); 418 | console.log(`Deleted: ${repoPath}`); 419 | } 420 | 421 | console.log('\nAll repos have been cleared.'); 422 | }).pipe(Effect.provide(programLayer)) 423 | ); 424 | 425 | // config repos - parent command for repo subcommands 426 | const configReposCommand = Command.make('repos', {}, () => 427 | Effect.sync(() => { 428 | console.log('Usage: btca config repos '); 429 | console.log(''); 430 | console.log('Commands:'); 431 | console.log(' list List all configured repos'); 432 | console.log(' add Add a new repo'); 433 | console.log(' remove Remove a configured repo'); 434 | console.log(' clear Clear all downloaded repos'); 435 | }) 436 | ).pipe( 437 | Command.withSubcommands([ 438 | configReposListCommand, 439 | configReposAddCommand, 440 | configReposRemoveCommand, 441 | configReposClearCommand 442 | ]) 443 | ); 444 | 445 | // config - parent command 446 | const configCommand = Command.make('config', {}, () => 447 | Effect.gen(function* () { 448 | const config = yield* ConfigService; 449 | const configPath = yield* config.getConfigPath(); 450 | 451 | console.log(`Config file: ${configPath}`); 452 | console.log(''); 453 | console.log('Usage: btca config '); 454 | console.log(''); 455 | console.log('Commands:'); 456 | console.log(' model View or set the model and provider'); 457 | console.log(' repos Manage configured repos'); 458 | }).pipe(Effect.provide(programLayer)) 459 | ).pipe(Command.withSubcommands([configModelCommand, configReposCommand])); 460 | 461 | // === Main Command === 462 | const mainCommand = Command.make('btca', {}, () => 463 | Effect.sync(() => { 464 | console.log(`btca v${VERSION}. run btca --help for more information.`); 465 | }) 466 | ).pipe( 467 | Command.withSubcommands([askCommand, serveCommand, openCommand, chatCommand, configCommand]) 468 | ); 469 | 470 | const cliService = Effect.gen(function* () { 471 | return { 472 | run: (argv: string[]) => 473 | Command.run(mainCommand, { 474 | name: 'btca', 475 | version: VERSION 476 | })(argv) 477 | }; 478 | }); 479 | 480 | export class CliService extends Effect.Service()('CliService', { 481 | effect: cliService 482 | }) {} 483 | 484 | export { type OcEvent }; 485 | -------------------------------------------------------------------------------- /apps/web/src/routes/getting-started/+page.svelte: -------------------------------------------------------------------------------- 1 | 120 | 121 |
122 |
123 |
124 | Getting started 128 | 129 |
130 | 131 |

134 | Getting started with btca 135 |

136 | 137 |

140 | Install btca, add it to your agent rules, and start asking questions 142 |

143 |
144 | 145 |
146 |

147 | Install 148 |

149 |

150 | Install globally with Bun, then run 151 | btca --help. 153 |

154 | 155 |
158 |
159 |
160 | {#if shikiStore.highlighter} 161 | {@html shikiStore.highlighter.codeToHtml(INSTALL_CMD, { 162 | theme: shikiTheme, 163 | lang: 'bash', 164 | rootStyle: 'background-color: transparent; padding: 0; margin: 0;' 165 | })} 166 | {:else} 167 |
{INSTALL_CMD}
171 | {/if} 172 |
173 | 174 |
175 |
176 | 177 |
180 |
181 | Ships with these repos by default 182 |
183 |
    184 | {#each DEFAULT_REPOS as repo} 185 |
  • 186 |
    187 | {repo.name} 190 | {repo.branch} 193 | {repo.url} 195 |
    196 |
  • 197 | {/each} 198 |
199 |
200 |
201 | 202 |
203 |

204 | Add it to a project 205 |

206 |

207 | Paste this into your project's AGENTS.md 210 | so your agent knows when to use btca. 211 | 212 | Make sure you update the list of technologies to match the ones you have added to btca 213 | config and need in this project. 214 | 215 |

216 | 217 |
220 |
221 | 227 | 228 |
229 |
230 |
231 | 232 |
233 |

234 | Using btca 235 |

236 |

237 | Most of the time you'll use ask. Use 240 | chat for an 241 | interactive session, and 242 | serve when you 243 | want an HTTP API. 244 |

245 | 246 |
247 |
250 |
Ask
251 |
252 | Answer a single question 253 |
254 |
257 |
258 |
259 | {#if shikiStore.highlighter} 260 | {@html shikiStore.highlighter.codeToHtml(ASK_CMD, { 261 | theme: shikiTheme, 262 | lang: 'bash', 263 | rootStyle: 'background-color: transparent; padding: 0; margin: 0;' 264 | })} 265 | {:else} 266 |
{ASK_CMD}
270 | {/if} 271 |
272 | 273 |
274 |
275 |
276 | 277 |
280 |
Chat
281 |
282 | Open an interactive session 283 |
284 |
287 |
288 |
289 | {#if shikiStore.highlighter} 290 | {@html shikiStore.highlighter.codeToHtml(CHAT_CMD, { 291 | theme: shikiTheme, 292 | lang: 'bash', 293 | rootStyle: 'background-color: transparent; padding: 0; margin: 0;' 294 | })} 295 | {:else} 296 |
{CHAT_CMD}
300 | {/if} 301 |
302 | 303 |
304 |
305 |
306 | 307 |
310 |
Serve
311 |
312 | Expose an HTTP endpoint for questions. 313 |
314 |
317 |
318 |
319 | {#if shikiStore.highlighter} 320 | {@html shikiStore.highlighter.codeToHtml(SERVE_CMD, { 321 | theme: shikiTheme, 322 | lang: 'bash', 323 | rootStyle: 'background-color: transparent; padding: 0; margin: 0;' 324 | })} 325 | {:else} 326 |
{SERVE_CMD}
330 | {/if} 331 |
332 | 333 |
334 |
335 |
336 | POST /question 339 | with 340 | {"tech","question"}. 343 |
344 |
345 |
346 |
347 | 348 |
349 |

350 | Add tech to btca 351 |

352 |

353 | Click a technology to copy the command that adds it to your btca config. 354 |

355 | 356 |
357 | {#each TECHS as tech} 358 | 385 | {/each} 386 |
387 |
388 | 389 |
390 |

391 | Set the model 392 |

393 |

394 | You can set provider + model via btca config model. I recommend using Haiku for fast, cheap answers. 398 |

399 | 400 |
403 |
404 |
405 | {#if shikiStore.highlighter} 406 | {@html shikiStore.highlighter.codeToHtml(MODEL_CMD, { 407 | theme: shikiTheme, 408 | lang: 'bash', 409 | rootStyle: 'background-color: transparent; padding: 0; margin: 0;' 410 | })} 411 | {:else} 412 |
{MODEL_CMD}
416 | {/if} 417 |
418 | 419 |
420 |
421 |
422 | 423 |
424 |

425 | Full config 426 |

427 |

428 | The config lives at ~/.config/btca/btca.json. You can print the path by running 432 | btca config. 434 |

435 | 436 |
439 |
440 |
441 | {#if shikiStore.highlighter} 442 | {@html shikiStore.highlighter.codeToHtml(FULL_CONFIG_JSON, { 443 | theme: shikiTheme, 444 | lang: 'json', 445 | rootStyle: 'background-color: transparent; padding: 0; margin: 0;' 446 | })} 447 | {:else} 448 |
{FULL_CONFIG_JSON}
452 | {/if} 453 |
454 | 455 |
456 |
457 |
458 |
459 | -------------------------------------------------------------------------------- /apps/cli/src/tui/App.tsx: -------------------------------------------------------------------------------- 1 | import { useKeyboard } from '@opentui/react'; 2 | import { useState, useMemo, useEffect, useRef } from 'react'; 3 | import type { InputRenderable } from '@opentui/core'; 4 | import { colors } from './theme.ts'; 5 | import { filterCommands } from './commands.ts'; 6 | import { services } from './services.ts'; 7 | import { copyToClipboard } from './clipboard.ts'; 8 | import type { Mode, Message, Repo, Command } from './types.ts'; 9 | import type { WizardStep } from './components/AddRepoWizard.tsx'; 10 | import type { ModelConfigStep } from './components/ModelConfig.tsx'; 11 | import { CommandPalette } from './components/CommandPalette.tsx'; 12 | import { AddRepoWizard } from './components/AddRepoWizard.tsx'; 13 | import { RemoveRepoPrompt } from './components/RemoveRepoPrompt.tsx'; 14 | import { ModelConfig } from './components/ModelConfig.tsx'; 15 | 16 | declare const __VERSION__: string; 17 | const VERSION: string = typeof __VERSION__ !== 'undefined' ? __VERSION__ : '0.0.0-dev'; 18 | 19 | const parseAtMention = ( 20 | input: string 21 | ): { repoQuery: string; question: string; hasSpace: boolean } | null => { 22 | if (!input.startsWith('@')) return null; 23 | const spaceIndex = input.indexOf(' '); 24 | if (spaceIndex === -1) { 25 | return { repoQuery: input.slice(1), question: '', hasSpace: false }; 26 | } 27 | return { 28 | repoQuery: input.slice(1, spaceIndex), 29 | question: input.slice(spaceIndex + 1), 30 | hasSpace: true 31 | }; 32 | }; 33 | 34 | export function App() { 35 | const [repos, setRepos] = useState([]); 36 | const [messages, setMessages] = useState([]); 37 | const [modelConfig, setModelConfig] = useState({ provider: '', model: '' }); 38 | 39 | const [mode, setMode] = useState('chat'); 40 | const [inputValue, setInputValue] = useState(''); 41 | const [commandIndex, setCommandIndex] = useState(0); 42 | 43 | const [repoMentionIndex, setRepoMentionIndex] = useState(0); 44 | 45 | const inputRef = useRef(null); 46 | 47 | const [wizardStep, setWizardStep] = useState('name'); 48 | const [wizardValues, setWizardValues] = useState({ 49 | name: '', 50 | url: '', 51 | branch: '', 52 | notes: '' 53 | }); 54 | const [wizardInput, setWizardInput] = useState(''); 55 | 56 | const [modelStep, setModelStep] = useState('provider'); 57 | const [modelValues, setModelValues] = useState({ provider: '', model: '' }); 58 | const [modelInput, setModelInput] = useState(''); 59 | 60 | const [removeRepoName, setRemoveRepoName] = useState(''); 61 | 62 | const [isLoading, setIsLoading] = useState(false); 63 | const [loadingText, setLoadingText] = useState(''); 64 | 65 | useEffect(() => { 66 | services.getRepos().then(setRepos).catch(console.error); 67 | services.getModel().then(setModelConfig).catch(console.error); 68 | }, []); 69 | 70 | const showCommandPalette = mode === 'chat' && inputValue.startsWith('/'); 71 | const commandQuery = inputValue.slice(1); 72 | const filteredCommands = useMemo(() => filterCommands(commandQuery), [commandQuery]); 73 | const clampedCommandIndex = Math.min(commandIndex, Math.max(0, filteredCommands.length - 1)); 74 | 75 | const atMention = useMemo(() => parseAtMention(inputValue), [inputValue]); 76 | const showRepoMentionPalette = mode === 'chat' && atMention !== null && !atMention.hasSpace; 77 | const filteredMentionRepos = useMemo(() => { 78 | if (!atMention) return []; 79 | const query = atMention.repoQuery.toLowerCase(); 80 | return repos.filter((repo) => repo.name.toLowerCase().includes(query)); 81 | }, [repos, atMention]); 82 | const clampedRepoMentionIndex = Math.min( 83 | repoMentionIndex, 84 | Math.max(0, filteredMentionRepos.length - 1) 85 | ); 86 | 87 | const handleInputChange = (value: string) => { 88 | setInputValue(value); 89 | setCommandIndex(0); 90 | setRepoMentionIndex(0); 91 | }; 92 | 93 | const executeCommand = async (command: Command) => { 94 | setInputValue(''); 95 | setCommandIndex(0); 96 | 97 | if (command.mode === 'add-repo') { 98 | setMode('add-repo'); 99 | setWizardStep('name'); 100 | setWizardValues({ name: '', url: '', branch: '', notes: '' }); 101 | setWizardInput(''); 102 | } else if (command.mode === 'clear') { 103 | setMessages([]); 104 | setMessages((prev) => [...prev, { role: 'system', content: 'Chat cleared.' }]); 105 | } else if (command.mode === 'remove-repo') { 106 | if (repos.length === 0) { 107 | setMessages((prev) => [...prev, { role: 'system', content: 'No repos to remove' }]); 108 | return; 109 | } 110 | setRemoveRepoName(''); 111 | setMode('remove-repo'); 112 | } else if (command.mode === 'config-model') { 113 | setMode('config-model'); 114 | setModelStep('provider'); 115 | setModelValues({ provider: modelConfig.provider, model: modelConfig.model }); 116 | setModelInput(modelConfig.provider); 117 | } else if (command.mode === 'chat') { 118 | setMessages((prev) => [ 119 | ...prev, 120 | { 121 | role: 'system', 122 | content: 'Use @reponame to start a chat. Example: @daytona How do I...?' 123 | } 124 | ]); 125 | } else if (command.mode === 'ask') { 126 | setMessages((prev) => [ 127 | ...prev, 128 | { 129 | role: 'system', 130 | content: 'Use @reponame to ask a question. Example: @daytona What is...?' 131 | } 132 | ]); 133 | } 134 | }; 135 | 136 | const selectRepoMention = () => { 137 | const selectedRepo = filteredMentionRepos[clampedRepoMentionIndex]; 138 | if (!selectedRepo) return; 139 | const newValue = `@${selectedRepo.name} `; 140 | setInputValue(newValue); 141 | setRepoMentionIndex(0); 142 | setTimeout(() => { 143 | if (inputRef.current) { 144 | inputRef.current.cursorPosition = newValue.length; 145 | } 146 | }, 0); 147 | }; 148 | 149 | const handleChatSubmit = async () => { 150 | const value = inputValue.trim(); 151 | if (!value) return; 152 | 153 | if (showCommandPalette && filteredCommands.length > 0) { 154 | const command = filteredCommands[clampedCommandIndex]; 155 | if (command) { 156 | executeCommand(command); 157 | return; 158 | } 159 | } 160 | 161 | if (showRepoMentionPalette && filteredMentionRepos.length > 0) { 162 | selectRepoMention(); 163 | return; 164 | } 165 | 166 | if (isLoading) return; 167 | 168 | const mention = parseAtMention(value); 169 | if (!mention || !mention.question.trim()) { 170 | setMessages((prev) => [ 171 | ...prev, 172 | { 173 | role: 'system', 174 | content: 'Use @reponame followed by your question. Example: @daytona How do I...?' 175 | } 176 | ]); 177 | return; 178 | } 179 | 180 | const targetRepo = repos.find((r) => r.name.toLowerCase() === mention.repoQuery.toLowerCase()); 181 | if (!targetRepo) { 182 | setMessages((prev) => [ 183 | ...prev, 184 | { 185 | role: 'system', 186 | content: `Repo "${mention.repoQuery}" not found. Use /add to add a repo.` 187 | } 188 | ]); 189 | return; 190 | } 191 | 192 | setMessages((prev) => [ 193 | ...prev, 194 | { role: 'user', content: `@${targetRepo.name} ${mention.question}` } 195 | ]); 196 | setInputValue(''); 197 | setIsLoading(true); 198 | setMode('loading'); 199 | setLoadingText(''); 200 | 201 | let fullResponse = ''; 202 | 203 | try { 204 | await services.askQuestion(targetRepo.name, mention.question, (event) => { 205 | if ( 206 | event.type === 'message.part.updated' && 207 | 'part' in event.properties && 208 | event.properties.part?.type === 'text' 209 | ) { 210 | const delta = (event.properties as { delta?: string }).delta ?? ''; 211 | fullResponse += delta; 212 | setLoadingText(fullResponse); 213 | } 214 | }); 215 | 216 | await copyToClipboard(fullResponse); 217 | 218 | setMessages((prev) => [ 219 | ...prev, 220 | { role: 'assistant', content: fullResponse }, 221 | { role: 'system', content: 'Answer copied to clipboard!' } 222 | ]); 223 | } catch (error) { 224 | setMessages((prev) => [...prev, { role: 'system', content: `Error: ${error}` }]); 225 | } finally { 226 | setIsLoading(false); 227 | setMode('chat'); 228 | setLoadingText(''); 229 | } 230 | }; 231 | 232 | const handleWizardSubmit = () => { 233 | const value = wizardInput.trim(); 234 | 235 | if (wizardStep === 'name') { 236 | if (!value) return; 237 | setWizardValues((prev) => ({ ...prev, name: value })); 238 | setWizardStep('url'); 239 | setWizardInput(''); 240 | } else if (wizardStep === 'url') { 241 | if (!value) return; 242 | setWizardValues((prev) => ({ ...prev, url: value })); 243 | setWizardStep('branch'); 244 | setWizardInput('main'); 245 | } else if (wizardStep === 'branch') { 246 | setWizardValues((prev) => ({ ...prev, branch: value || 'main' })); 247 | setWizardStep('notes'); 248 | setWizardInput(''); 249 | } else if (wizardStep === 'notes') { 250 | setWizardValues((prev) => ({ ...prev, notes: value })); 251 | setWizardStep('confirm'); 252 | } else if (wizardStep === 'confirm') { 253 | const newRepo: Repo = { 254 | name: wizardValues.name, 255 | url: wizardValues.url, 256 | branch: wizardValues.branch || 'main', 257 | ...(wizardValues.notes && { specialNotes: wizardValues.notes }) 258 | }; 259 | 260 | services 261 | .addRepo(newRepo) 262 | .then(() => { 263 | setRepos((prev) => [...prev, newRepo]); 264 | setMessages((prev) => [ 265 | ...prev, 266 | { role: 'system', content: `Added repo: ${newRepo.name}` } 267 | ]); 268 | }) 269 | .catch((error) => { 270 | setMessages((prev) => [...prev, { role: 'system', content: `Error: ${error}` }]); 271 | }) 272 | .finally(() => { 273 | setMode('chat'); 274 | }); 275 | } 276 | }; 277 | 278 | const handleModelSubmit = () => { 279 | const value = modelInput.trim(); 280 | 281 | if (modelStep === 'provider') { 282 | if (!value) return; 283 | setModelValues((prev) => ({ ...prev, provider: value })); 284 | setModelStep('model'); 285 | setModelInput(modelConfig.model); 286 | } else if (modelStep === 'model') { 287 | if (!value) return; 288 | setModelValues((prev) => ({ ...prev, model: value })); 289 | setModelStep('confirm'); 290 | } else if (modelStep === 'confirm') { 291 | services 292 | .updateModel(modelValues.provider, modelValues.model) 293 | .then((result) => { 294 | setModelConfig(result); 295 | setMessages((prev) => [ 296 | ...prev, 297 | { 298 | role: 'system', 299 | content: `Model updated: ${result.provider}/${result.model}` 300 | } 301 | ]); 302 | }) 303 | .catch((error) => { 304 | setMessages((prev) => [...prev, { role: 'system', content: `Error: ${error}` }]); 305 | }) 306 | .finally(() => { 307 | setMode('chat'); 308 | }); 309 | } 310 | }; 311 | 312 | const handleRemoveRepo = async (repoName: string) => { 313 | try { 314 | await services.removeRepo(repoName); 315 | setRepos((prev) => prev.filter((r) => r.name !== repoName)); 316 | setMessages((prev) => [...prev, { role: 'system', content: `Removed repo: ${repoName}` }]); 317 | } catch (error) { 318 | setMessages((prev) => [...prev, { role: 'system', content: `Error: ${error}` }]); 319 | } finally { 320 | setMode('chat'); 321 | setRemoveRepoName(''); 322 | } 323 | }; 324 | 325 | const cancelMode = () => { 326 | setMode('chat'); 327 | setInputValue(''); 328 | setWizardInput(''); 329 | setModelInput(''); 330 | setRemoveRepoName(''); 331 | }; 332 | 333 | useKeyboard((key) => { 334 | if (key.name === 'escape') { 335 | key.preventDefault(); 336 | if (mode !== 'chat' && mode !== 'loading') { 337 | cancelMode(); 338 | } else if (showCommandPalette || showRepoMentionPalette) { 339 | setInputValue(''); 340 | } 341 | return; 342 | } 343 | 344 | if (mode === 'chat' && showCommandPalette) { 345 | if (key.name === 'up') { 346 | key.preventDefault(); 347 | setCommandIndex((prev) => (prev === 0 ? filteredCommands.length - 1 : prev - 1)); 348 | } else if (key.name === 'down') { 349 | key.preventDefault(); 350 | setCommandIndex((prev) => (prev === filteredCommands.length - 1 ? 0 : prev + 1)); 351 | } 352 | } else if (mode === 'chat' && showRepoMentionPalette) { 353 | if (key.name === 'up') { 354 | key.preventDefault(); 355 | setRepoMentionIndex((prev) => (prev === 0 ? filteredMentionRepos.length - 1 : prev - 1)); 356 | } else if (key.name === 'down') { 357 | key.preventDefault(); 358 | setRepoMentionIndex((prev) => (prev === filteredMentionRepos.length - 1 ? 0 : prev + 1)); 359 | } else if (key.name === 'tab') { 360 | key.preventDefault(); 361 | selectRepoMention(); 362 | } 363 | } else if (mode === 'remove-repo') { 364 | if (key.name === 'y' || key.name === 'Y') { 365 | key.preventDefault(); 366 | if (removeRepoName) { 367 | handleRemoveRepo(removeRepoName); 368 | } 369 | } else if (key.name === 'n' || key.name === 'N') { 370 | key.preventDefault(); 371 | cancelMode(); 372 | } 373 | } else if (mode === 'add-repo' && wizardStep === 'confirm') { 374 | if (key.name === 'return') { 375 | key.preventDefault(); 376 | handleWizardSubmit(); 377 | } 378 | } else if (mode === 'config-model' && modelStep === 'confirm') { 379 | if (key.name === 'return') { 380 | key.preventDefault(); 381 | handleModelSubmit(); 382 | } 383 | } 384 | }); 385 | 386 | return ( 387 | 395 | 409 | 410 | {'◆'} 411 | {' btca'} 412 | {' - The Better Context App'} 413 | 414 | 415 | 416 | 417 | 424 | 434 | 435 | 436 | {messages.map((msg, i) => ( 437 | 438 | 447 | {msg.role === 'user' ? 'You ' : msg.role === 'system' ? 'SYS ' : 'AI '} 448 | 449 | 450 | 451 | ))} 452 | {mode === 'loading' && ( 453 | 454 | {'AI '} 455 | 456 | 457 | )} 458 | 459 | 460 | 461 | {showCommandPalette && ( 462 | 467 | )} 468 | 469 | {showRepoMentionPalette && filteredMentionRepos.length > 0 && ( 470 | 483 | 484 | {(() => { 485 | const maxVisible = 8; 486 | const start = Math.max( 487 | 0, 488 | Math.min( 489 | clampedRepoMentionIndex - Math.floor(maxVisible / 2), 490 | filteredMentionRepos.length - maxVisible 491 | ) 492 | ); 493 | const visibleRepos = filteredMentionRepos.slice(start, start + maxVisible); 494 | return visibleRepos.map((repo, i) => { 495 | const actualIndex = start + i; 496 | return ( 497 | 504 | ); 505 | }); 506 | })()} 507 | 508 | )} 509 | 510 | {mode === 'add-repo' && ( 511 | 519 | )} 520 | 521 | {mode === 'remove-repo' && !removeRepoName && ( 522 | 535 | 536 | 537 | 538 | 539 | {repos.map((repo) => ( 540 | 541 | ))} 542 | 543 | { 550 | const repo = repos.find((r) => r.name.toLowerCase() === removeRepoName.toLowerCase()); 551 | if (repo) { 552 | setRemoveRepoName(repo.name); 553 | } 554 | }} 555 | focused={true} 556 | style={{ height: 1, width: '100%', marginTop: 1 }} 557 | /> 558 | 559 | )} 560 | 561 | {mode === 'remove-repo' && removeRepoName && ( 562 | 563 | )} 564 | 565 | {mode === 'config-model' && ( 566 | 574 | )} 575 | 576 | 586 | 603 | 604 | 605 | 616 | 640 | 641 | 642 | 643 | ); 644 | } 645 | --------------------------------------------------------------------------------