diff --git a/README.md b/README.md index 7b60346..5971721 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,18 @@ Wright is a generalized dev automation platform that takes task descriptions, uses the Claude Agent SDK to generate code, runs tests iteratively (the Ralph Loop pattern), and creates pull requests -- with a Telegram bot for human-in-the-loop approval. +## Demo + +Submit a task via Telegram, watch Wright clone the repo, edit code, run tests, and create a PR -- all automated. + +### Desktop + +![Desktop demo — task submission, progress, completion, and GitHub PR](docs/screenshots/demo-desktop.webp) + +### Mobile + +![Mobile demo — task submission, progress, completion, and PR review](docs/screenshots/demo-mobile.webp) + ## Test Results **53 tests passing** across 6 test suites, covering the full pipeline from detection to dev loop execution. diff --git a/apps/bot/fly.toml b/apps/bot/fly.toml index 23b1967..611ffb9 100644 --- a/apps/bot/fly.toml +++ b/apps/bot/fly.toml @@ -1,5 +1,7 @@ # Fly.io configuration for the wright Telegram bot # Long-polling bot — no HTTP service needed, just a persistent process +# +# Deploy from repo root: fly deploy -c apps/bot/fly.toml app = "wright-bot" primary_region = "ord" diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 6dc8d06..0468f17 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -6,11 +6,13 @@ * keyboard buttons for approve / reject. */ +import { execFileSync } from 'child_process' import { Bot, InlineKeyboard, type Context } from 'grammy' import { JOB_STATUS, type Job, type JobEvent } from '@wright/shared' import { insertJob, getJob, + getJobByPrefix, cancelJob, getJobEvents, subscribeToJobEvents, @@ -39,6 +41,19 @@ if (!GITHUB_TOKEN) { const bot = new Bot(BOT_TOKEN) +// --------------------------------------------------------------------------- +// Notification mode: 'verbose' (default) or 'quiet', keyed by chat ID +// --------------------------------------------------------------------------- + +// TODO: persist to Supabase so mode survives bot restarts +const chatNotifyMode = new Map() + +/** Events that are skipped entirely in quiet mode. */ +const QUIET_EVENTS = new Set(['edit', 'test_run', 'cloned']) + +/** Events that always deliver with notification sound regardless of mode. */ +const LOUD_EVENTS = new Set(['pr_created', 'completed']) + // --------------------------------------------------------------------------- // Authorization middleware — restrict to known Telegram users // --------------------------------------------------------------------------- @@ -152,6 +167,36 @@ function isValidRepoUrl(url: string): boolean { } } +/** + * Extract a job ID from a bot message (looks for patterns like "Job ID: " + * or "[]" in the message text). + */ +function extractJobIdFromMessage(text: string): string | null { + // Full UUID pattern: "Job ID: xxxxxxxx-xxxx-..." + const fullMatch = text.match(/Job ID:\s*([0-9a-f-]{36})/i) + if (fullMatch) return fullMatch[1] + // Full UUID inside "Job xxxxxxxx" (short ID in status messages) + const shortMatch = text.match(/Job\s+([0-9a-f]{8})\b/i) + if (shortMatch) return shortMatch[1] + // Bracket prefix: "[xxxxxxxx]" + const bracketMatch = text.match(/\[([0-9a-f]{8})\]/) + if (bracketMatch) return bracketMatch[1] + return null +} + +/** + * Parse a GitHub PR URL into its components. + * Returns null if the URL is not a valid PR URL. + */ +function parsePrUrl(url: string): { repoUrl: string; prNumber: number } | null { + const match = url.match(/^https?:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/) + if (!match) return null + return { + repoUrl: `https://github.com/${match[1]}`, + prNumber: parseInt(match[2], 10), + } +} + /** Build PR approval inline keyboard. */ function buildPrKeyboard(jobId: string, prUrl: string): InlineKeyboard { return new InlineKeyboard() @@ -174,8 +219,14 @@ bot.command('start', async (ctx: Context) => { '', 'Commands:', '/task <repo_url> <description> -- Submit a dev task', + '/task <pr_url> <feedback> -- Revise an existing PR', + '/revise <job_id> <feedback> -- Revise a job\'s PR', '/status <job_id> -- Check job status', '/cancel <job_id> -- Cancel a running job', + '/verbose -- Enable all event notifications (default)', + '/quiet -- Only send milestone notifications; silence noisy events', + '', + 'You can also reply to any job message with feedback to revise its PR.', '', 'When a PR is ready, I will send approve/reject buttons.', ].join('\n'), @@ -183,6 +234,95 @@ bot.command('start', async (ctx: Context) => { ) }) +bot.command('verbose', async (ctx: Context) => { + const chatId = ctx.chat!.id + chatNotifyMode.set(chatId, 'verbose') + await ctx.reply('\u{1F50A} Verbose mode enabled. You will receive all event notifications.') +}) + +bot.command('quiet', async (ctx: Context) => { + const chatId = ctx.chat!.id + chatNotifyMode.set(chatId, 'quiet') + await ctx.reply( + '\u{1F514} Quiet mode enabled. ' + + 'Only milestone notifications (loop start, test results, PR created, completed, errors) will be sent. ' + + 'Noisy events (edits, test runs, cloned) are silenced.', + ) +}) + +bot.command('revise', async (ctx: Context) => { + const text = ctx.message?.text ?? '' + const parts = text.split(/\s+/) + + if (parts.length < 3) { + await ctx.reply( + 'Usage: /revise <job_id> <feedback>\n\n' + + 'Pushes changes to the existing PR branch based on your feedback.', + { parse_mode: 'HTML' }, + ) + return + } + + const jobIdInput = parts[1] + const feedback = parts.slice(2).join(' ') + + try { + // Look up the original job — support both full UUID and 8-char prefix + const originalJob = await getJob(jobIdInput) ?? await getJobByPrefix(jobIdInput) + if (!originalJob) { + await ctx.reply( + `No job found with ID ${escapeHtml(jobIdInput)}.`, + { parse_mode: 'HTML' }, + ) + return + } + + if (!originalJob.pr_url) { + await ctx.reply('That job has no PR yet. Cannot revise.') + return + } + + // Determine the feature branch from the original job + const featureBranch = originalJob.feature_branch || `wright/${originalJob.id.slice(0, 8)}` + + const ack = await ctx.reply( + `\u{1F504} Queuing revision for ${featureBranch}...`, + { parse_mode: 'HTML' }, + ) + + const job = await insertJob({ + repoUrl: originalJob.repo_url, + task: feedback, + chatId: ctx.chat!.id, + messageId: ack.message_id, + githubToken: GITHUB_TOKEN, + branch: originalJob.branch, + featureBranch, + parentJobId: originalJob.id, + }) + + await ctx.reply( + [ + '\u{1F504} Revision queued!', + '', + `Job ID: ${job.id}`, + `Revising: ${originalJob.id.slice(0, 8)}`, + `Branch: ${featureBranch}`, + `Feedback: ${escapeHtml(feedback)}`, + '', + 'The worker will push changes to the existing PR branch.', + ].join('\n'), + { parse_mode: 'HTML' }, + ) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + await ctx.reply( + `\u{274C} Failed to queue revision: ${escapeHtml(msg)}`, + { parse_mode: 'HTML' }, + ) + } +}) + bot.command('task', async (ctx: Context) => { const text = ctx.message?.text ?? '' // Parse: /task @@ -200,9 +340,85 @@ bot.command('task', async (ctx: Context) => { return } - const repoUrl = parts[1] + const urlArg = parts[1] const description = parts.slice(2).join(' ') + // Detect PR URL — treat as revision of that PR + const prInfo = parsePrUrl(urlArg) + if (prInfo) { + if (description.length < 5) { + await ctx.reply('Please provide feedback for the PR revision (at least 5 characters).') + return + } + + const ack = await ctx.reply( + `\u{1F504} Detected PR #${prInfo.prNumber}. Queuing revision...`, + { parse_mode: 'HTML' }, + ) + + try { + // Look up the PR's head branch via GitHub API + const ghEnv: Record = { + PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin', + HOME: process.env.HOME || '/home/wright', + } + if (GITHUB_TOKEN) ghEnv.GH_TOKEN = GITHUB_TOKEN + + const nwo = prInfo.repoUrl.replace('https://github.com/', '') + const headRef = execFileSync( + 'gh', + ['api', `repos/${nwo}/pulls/${prInfo.prNumber}`, '--jq', '.head.ref'], + { encoding: 'utf-8', env: ghEnv }, + ).trim() + + // Try to find the original job that created this branch so we can link them + const { getSupabase } = await import('./supabase.js') + const sb = getSupabase() + const { data: parentJobs } = await sb + .from('job_queue') + .select('id') + .or(`feature_branch.eq.${headRef},id.like.${headRef.replace('wright/', '')}%`) + .not('pr_url', 'is', null) + .order('created_at', { ascending: false }) + .limit(1) + const parentJobId = parentJobs?.[0]?.id + + const job = await insertJob({ + repoUrl: prInfo.repoUrl, + task: description, + chatId: ctx.chat!.id, + messageId: ack.message_id, + githubToken: GITHUB_TOKEN, + featureBranch: headRef, + parentJobId, + }) + + await ctx.reply( + [ + '\u{1F504} PR revision queued!', + '', + `Job ID: ${job.id}`, + `PR: #${prInfo.prNumber}`, + `Branch: ${headRef}`, + `Feedback: ${escapeHtml(description)}`, + '', + 'The worker will push changes to the existing PR branch.', + ].join('\n'), + { parse_mode: 'HTML' }, + ) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + await ctx.reply( + `\u{274C} Failed to queue PR revision: ${escapeHtml(msg)}`, + { parse_mode: 'HTML' }, + ) + } + return + } + + // Normal task: repo URL + description + const repoUrl = urlArg + if (!isValidRepoUrl(repoUrl)) { await ctx.reply( 'That does not look like a valid repository URL. ' @@ -344,6 +560,84 @@ bot.command('cancel', async (ctx: Context) => { } }) +// --------------------------------------------------------------------------- +// Reply-based revision — reply to any job message to revise the PR +// --------------------------------------------------------------------------- + +bot.on('message:text', async (ctx, next) => { + const reply = ctx.message.reply_to_message + // Only handle replies to bot messages that contain a job ID + if (!reply || reply.from?.id !== ctx.me.id) { + return next() + } + + const replyText = reply.text || reply.caption || '' + const jobIdPrefix = extractJobIdFromMessage(replyText) + if (!jobIdPrefix) return next() + + const feedback = ctx.message.text + // Ignore commands — let command handlers take care of those + if (feedback.startsWith('/')) return next() + + if (feedback.length < 5) { + await ctx.reply('Please provide more detailed feedback (at least 5 characters).') + return + } + + try { + // Look up the original job — try full ID first, then prefix match + const originalJob = await getJob(jobIdPrefix) ?? await getJobByPrefix(jobIdPrefix) + if (!originalJob) { + await ctx.reply( + `Could not find job ${escapeHtml(jobIdPrefix)}.`, + { parse_mode: 'HTML' }, + ) + return + } + + if (!originalJob.pr_url) { + await ctx.reply('That job has no PR yet. Cannot revise.') + return + } + + const featureBranch = originalJob.feature_branch || `wright/${originalJob.id.slice(0, 8)}` + + const ack = await ctx.reply( + `\u{1F504} Queuing revision for ${featureBranch} based on your reply...`, + { parse_mode: 'HTML' }, + ) + + const job = await insertJob({ + repoUrl: originalJob.repo_url, + task: feedback, + chatId: ctx.chat!.id, + messageId: ack.message_id, + githubToken: GITHUB_TOKEN, + branch: originalJob.branch, + featureBranch, + parentJobId: originalJob.id, + }) + + await ctx.reply( + [ + '\u{1F504} Revision queued from reply!', + '', + `Job ID: ${job.id}`, + `Revising: ${originalJob.id.slice(0, 8)}`, + `Branch: ${featureBranch}`, + `Feedback: ${escapeHtml(feedback.slice(0, 200))}`, + ].join('\n'), + { parse_mode: 'HTML' }, + ) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + await ctx.reply( + `\u{274C} Failed to queue revision: ${escapeHtml(msg)}`, + { parse_mode: 'HTML' }, + ) + } +}) + // --------------------------------------------------------------------------- // Inline keyboard callback queries (approve / reject PRs) // --------------------------------------------------------------------------- @@ -431,17 +725,28 @@ function startRealtimeBridge(): void { const job = await getJob(event.job_id) if (!job?.telegram_chat_id) return + const chatId = job.telegram_chat_id + const mode = chatNotifyMode.get(chatId) ?? 'verbose' + + // In quiet mode, skip noisy events entirely + if (mode === 'quiet' && QUIET_EVENTS.has(event.event_type)) return + const text = `[${job.id.slice(0, 8)}] ${formatJobEvent(event)}` + // In quiet mode, deliver silently unless this is a loud event + const disableNotification = mode === 'quiet' && !LOUD_EVENTS.has(event.event_type) + // PR created events get the approval keyboard if (event.event_type === 'pr_created' && job.pr_url) { - await bot.api.sendMessage(job.telegram_chat_id, text, { + await bot.api.sendMessage(chatId, text, { parse_mode: 'HTML', reply_markup: buildPrKeyboard(job.id, job.pr_url), + disable_notification: disableNotification, }) } else { - await bot.api.sendMessage(job.telegram_chat_id, text, { + await bot.api.sendMessage(chatId, text, { parse_mode: 'HTML', + disable_notification: disableNotification, }) } } catch (err) { @@ -458,16 +763,21 @@ function startRealtimeBridge(): void { if (!terminal.includes(job.status)) return try { + const chatId = job.telegram_chat_id + const mode = chatNotifyMode.get(chatId) ?? 'verbose' const text = formatJobStatus(job) if (job.pr_url && job.status === JOB_STATUS.SUCCEEDED) { - await bot.api.sendMessage(job.telegram_chat_id, text, { + await bot.api.sendMessage(chatId, text, { parse_mode: 'HTML', reply_markup: buildPrKeyboard(job.id, job.pr_url), }) } else { - await bot.api.sendMessage(job.telegram_chat_id, text, { + // Terminal failure: respect quiet mode (silent delivery) + const disableNotification = mode === 'quiet' && job.status === JOB_STATUS.FAILED + await bot.api.sendMessage(chatId, text, { parse_mode: 'HTML', + disable_notification: disableNotification, }) } } catch (err) { diff --git a/apps/bot/src/supabase.ts b/apps/bot/src/supabase.ts index fe0f41c..80ce269 100644 --- a/apps/bot/src/supabase.ts +++ b/apps/bot/src/supabase.ts @@ -50,6 +50,10 @@ export interface InsertJobParams { branch?: string maxLoops?: number maxBudgetUsd?: number + /** For revision jobs: the existing feature branch to push to */ + featureBranch?: string + /** For revision jobs: the parent job ID being revised */ + parentJobId?: string } /** @@ -60,20 +64,24 @@ export interface InsertJobParams { export async function insertJob(params: InsertJobParams): Promise { const sb = getSupabase() + const row: Record = { + repo_url: params.repoUrl, + task: params.task, + branch: params.branch ?? 'main', + max_loops: params.maxLoops ?? DEFAULT_MAX_LOOPS, + max_budget_usd: params.maxBudgetUsd ?? DEFAULT_MAX_BUDGET_USD, + status: JOB_STATUS.QUEUED, + total_cost_usd: 0, + github_token: params.githubToken, + telegram_chat_id: params.chatId, + telegram_message_id: params.messageId, + } + if (params.featureBranch) row.feature_branch = params.featureBranch + if (params.parentJobId) row.parent_job_id = params.parentJobId + const { data, error } = await sb .from(TABLES.JOB_QUEUE) - .insert({ - repo_url: params.repoUrl, - task: params.task, - branch: params.branch ?? 'main', - max_loops: params.maxLoops ?? DEFAULT_MAX_LOOPS, - max_budget_usd: params.maxBudgetUsd ?? DEFAULT_MAX_BUDGET_USD, - status: JOB_STATUS.QUEUED, - total_cost_usd: 0, - github_token: params.githubToken, - telegram_chat_id: params.chatId, - telegram_message_id: params.messageId, - }) + .insert(row) .select() .single() @@ -105,6 +113,27 @@ export async function getJob(jobId: string): Promise { return data as Job } +/** + * Fetch a job by an 8-char ID prefix. Returns null if not found or ambiguous. + */ +export async function getJobByPrefix(prefix: string): Promise { + const sb = getSupabase() + + const { data, error } = await sb + .from(TABLES.JOB_QUEUE) + .select('*') + .like('id', `${prefix}%`) + .limit(2) + + if (error) { + throw new Error(`Failed to fetch job by prefix: ${error.message}`) + } + + // Return null if no match or ambiguous (multiple matches) + if (!data || data.length !== 1) return null + return data[0] as Job +} + /** * Attempt to cancel a job by setting its status to 'failed' with a * cancellation error. Only queued or running jobs can be cancelled. diff --git a/apps/worker/src/dev-loop.ts b/apps/worker/src/dev-loop.ts index 04fe7ae..c5cb60e 100644 --- a/apps/worker/src/dev-loop.ts +++ b/apps/worker/src/dev-loop.ts @@ -11,7 +11,7 @@ import { createClient, type SupabaseClient } from '@supabase/supabase-js' import type { DevLoopConfig, DevLoopResult, TestResults, TestFailure } from '@wright/shared' import { MIN_BUDGET_PER_LOOP_USD, DEFAULT_MAX_TURNS_PER_LOOP } from '@wright/shared' -import { cloneRepo, createFeatureBranch, commitAndPush, createPullRequest } from './github-ops.js' +import { cloneRepo, createFeatureBranch, checkoutExistingBranch, commitAndPush, createPullRequest } from './github-ops.js' import { detectTestRunner, detectPackageManager, installDependencies, runTests } from './test-runner.js' import { runClaudeSession } from './claude-session.js' import { existsSync, rmSync, mkdirSync } from 'fs' @@ -55,13 +55,22 @@ export async function runDevLoop(config: DevLoopConfig): Promise } try { - // 1. Clone (checkout the specified base branch) + // 1. Clone the repository await emit(supabase, job.id, 'cloned', undefined, { message: 'Cloning repository...' }) - await cloneRepo(job.repo_url, workDir, job.github_token, job.branch) - // 2. Create feature branch from the base branch - const branchName = `wright/${job.id.slice(0, 8)}` - await createFeatureBranch(workDir, branchName) + // Revision jobs have a feature_branch set — clone main then checkout that branch. + // New jobs clone the base branch and create a fresh feature branch. + const isRevision = !!job.feature_branch + let branchName: string + + if (isRevision) { + await cloneRepo(job.repo_url, workDir, job.github_token) + branchName = await checkoutExistingBranch(workDir, job.feature_branch!) + } else { + await cloneRepo(job.repo_url, workDir, job.github_token, job.branch) + branchName = `wright/${job.id.slice(0, 8)}` + await createFeatureBranch(workDir, branchName) + } // 3. Auto-detect test runner and package manager const testRunner = job.test_runner || detectTestRunner(workDir) @@ -246,25 +255,55 @@ export async function runDevLoop(config: DevLoopConfig): Promise } // 6. Commit and push + // Try to read a Claude-generated PR title from the workdir + const prTitleFile = `${workDir}/.wright-pr-title` + let generatedTitle: string | undefined + try { + const { readFileSync } = await import('fs') + generatedTitle = readFileSync(prTitleFile, 'utf-8').trim().slice(0, 70) + // Clean up the file so it doesn't get committed + const { unlinkSync } = await import('fs') + unlinkSync(prTitleFile) + } catch { + // No title file — will fall back to task-based title + } + + const prTitle = generatedTitle || `feat: ${job.task.slice(0, 60)}` const commitMessage = allTestsPassed - ? `feat: ${job.task.slice(0, 60)}` + ? prTitle : `wip: ${job.task.slice(0, 60)} (${lastTestResults.passed}/${lastTestResults.total} tests passing)` const commitSha = await commitAndPush(workDir, commitMessage, job.github_token) - // 7. Create PR + // 7. Create PR (skip for revision jobs — the PR already exists) let prUrl: string | undefined - try { - prUrl = await createPullRequest( - workDir, - job.task.slice(0, 70), - buildPrBody(job.task, lastTestResults, loopsCompleted, totalCost), - job.branch, - job.github_token, - ) - await emit(supabase, job.id, 'pr_created', undefined, { prUrl }) - } catch (err) { - console.error('[dev-loop] Failed to create PR:', err) + if (isRevision) { + // For revisions we just pushed to the existing branch; the PR auto-updates. + // Look up the existing PR URL from the parent job. + if (job.parent_job_id) { + const { data: parentJob } = await supabase + .from('job_queue') + .select('pr_url') + .eq('id', job.parent_job_id) + .single() + prUrl = parentJob?.pr_url + } + if (prUrl) { + await emit(supabase, job.id, 'pr_created', undefined, { prUrl, revision: true }) + } + } else { + try { + prUrl = await createPullRequest( + workDir, + prTitle, + buildPrBody(job.task, lastTestResults, loopsCompleted, totalCost), + job.branch, + job.github_token, + ) + await emit(supabase, job.id, 'pr_created', undefined, { prUrl }) + } catch (err) { + console.error('[dev-loop] Failed to create PR:', err) + } } await emit(supabase, job.id, 'completed', undefined, { @@ -329,7 +368,10 @@ ${workDir} - NEVER modify package manifests (package.json, pyproject.toml, Cargo.toml, go.mod, setup.py, setup.cfg, etc.) unless the task explicitly requires adding or removing a dependency. - NEVER modify lock files (uv.lock, pnpm-lock.yaml, package-lock.json, yarn.lock, Cargo.lock, poetry.lock, etc.) under any circumstances. - If the task is about documentation (README, docs/, *.md), ONLY modify documentation files. Do NOT touch source code, tests, or manifests. -- When in doubt about whether a file is in scope, leave it unchanged.` +- When in doubt about whether a file is in scope, leave it unchanged. + +## PR Title +After completing your changes, write a short conventional-commit-style PR title (e.g. "feat: add ecosystem section to README" or "fix: correct broken link in docs") to the file \`.wright-pr-title\` in the repo root. The title MUST be under 70 characters. Do NOT include a PR body — just the title on a single line.` } function buildInitialPrompt( diff --git a/apps/worker/src/github-ops.ts b/apps/worker/src/github-ops.ts index 53068ba..48520cd 100644 --- a/apps/worker/src/github-ops.ts +++ b/apps/worker/src/github-ops.ts @@ -35,6 +35,20 @@ export async function createFeatureBranch( return branchName } +/** + * Check out an existing remote branch (e.g. for revision jobs that push to + * an already-open PR's branch). + */ +export async function checkoutExistingBranch( + workDir: string, + branchName: string, +): Promise { + const git = simpleGit(workDir) + await git.fetch('origin', branchName) + await git.checkout(branchName) + return branchName +} + export async function commitAndPush(workDir: string, message: string, githubToken?: string): Promise { const git = simpleGit(workDir) diff --git a/apps/worker/src/test-runner.ts b/apps/worker/src/test-runner.ts index 6f97ec9..4462f2e 100644 --- a/apps/worker/src/test-runner.ts +++ b/apps/worker/src/test-runner.ts @@ -3,6 +3,28 @@ import { existsSync, readFileSync } from 'fs' import { join } from 'path' import type { TestRunner, PackageManager, TestResults, TestFailure } from '@wright/shared' +/** + * Build a minimal env for subprocess execution, avoiding leaking secrets + * (SUPABASE_SERVICE_ROLE_KEY, ANTHROPIC_API_KEY, BOT_TOKEN, etc.). + */ +const SAFE_ENV_KEYS = [ + 'PATH', 'HOME', 'USER', 'SHELL', 'TERM', 'LANG', 'LC_ALL', + 'TMPDIR', 'TMP', 'TEMP', + 'NODE_ENV', 'WORKSPACE_DIR', + // Language-specific runtime env + 'GOPATH', 'GOROOT', 'CARGO_HOME', 'RUSTUP_HOME', + 'VIRTUAL_ENV', 'PYTHONPATH', + 'NODE_PATH', 'NPM_CONFIG_PREFIX', +] + +function getSafeEnv(): Record { + const env: Record = {} + for (const key of SAFE_ENV_KEYS) { + if (process.env[key]) env[key] = process.env[key]! + } + return env +} + /** * Auto-detect the test runner from repo files. */ @@ -94,7 +116,7 @@ export function installDependencies(workDir: string, pm: PackageManager): void { console.log(`[test-runner] Installing dependencies with ${pm}: ${cmd}`) try { - execSync(cmd, { cwd: workDir, stdio: 'pipe', timeout: 300_000 }) + execSync(cmd, { cwd: workDir, stdio: 'pipe', timeout: 300_000, env: getSafeEnv() }) } catch (err) { const stderr = err && typeof err === 'object' && 'stderr' in err ? String((err as { stderr: unknown }).stderr).slice(-2000) @@ -149,6 +171,7 @@ export function runTests( encoding: 'utf-8', timeout: timeoutSeconds * 1000, stdio: ['pipe', 'pipe', 'pipe'], + env: getSafeEnv(), }) } catch (err) { if (err && typeof err === 'object' && 'stdout' in err) { diff --git a/docs/guides/macos-desktop-restore.md b/docs/guides/macos-desktop-restore.md new file mode 100644 index 0000000..efc73a5 --- /dev/null +++ b/docs/guides/macos-desktop-restore.md @@ -0,0 +1,144 @@ +# Restoring the Local ~/Desktop Directory on macOS After iCloud Sync + +## Your macOS Version + +Darwin 24.6.0 corresponds to **macOS Sequoia 15.6** (build 24G84, released July 29, 2025). + +In macOS 15 Sequoia, Apple renamed "Apple ID" to **"Apple Account"** throughout System Settings. + +## Background + +When "Desktop & Documents Folders" is enabled in iCloud Drive settings, macOS +redirects `~/Desktop` and `~/Documents` to iCloud Drive. Files are stored in the +cloud and may appear locally only as stubs (cloud-only references). If iCloud +storage is full or network is slow, files can become inaccessible. + +## Step 1: Disable Desktop & Documents Folders Sync + +1. Open **System Settings** (Apple menu > System Settings). +2. Click your **name** at the top of the sidebar (labeled "Apple Account"). +3. Click **iCloud**. +4. Under **"Saved to iCloud"**, click **Drive**. +5. Toggle **off** "Desktop & Documents Folders". +6. Click **Done**. + +A confirmation dialog will appear stating: + +> "Items will be removed from the Desktop & Documents folder on this Mac and +> will remain available in iCloud Drive." + +Click **Turn Off** to confirm. + +## Step 2: Understand Where Files Went + +After turning off the toggle: + +- A **new, empty** `~/Desktop` and `~/Documents` folder is created locally in your home directory. +- Your **existing files remain in iCloud Drive** under folders named "Desktop" and "Documents". +- Files are NOT deleted. They simply lose the special "synced" status. + +## Step 3: Move Files Back to Local Desktop + +### Option A: Via Finder (Recommended) + +1. Open **Finder**. +2. In the menu bar, click **Go > iCloud Drive** (or press Shift+Cmd+I). +3. Inside iCloud Drive, you will see a **Desktop** folder (and a **Documents** folder). +4. Open the **Desktop** folder in iCloud Drive. +5. Select all files: **Cmd+A**. +6. **Drag** them to your local Desktop (visible in the Finder sidebar, or navigate to your home folder and open Desktop). + +Repeat for the Documents folder if needed. + +### Option B: Move and Delete from iCloud Simultaneously + +Hold **Cmd** while dragging files from the iCloud Drive Desktop folder to your +local Desktop. This performs a **move** (copy to new location, delete from old +location) rather than a copy, freeing up iCloud storage immediately. + +### Option C: Via Terminal + +```bash +# List what is in the iCloud Drive Desktop folder +ls ~/Library/Mobile\ Documents/com~apple~CloudDocs/Desktop/ + +# Copy everything back to local Desktop +cp -R ~/Library/Mobile\ Documents/com~apple~CloudDocs/Desktop/* ~/Desktop/ + +# Once verified, optionally remove from iCloud Drive +# rm -rf ~/Library/Mobile\ Documents/com~apple~CloudDocs/Desktop/* +``` + +## Step 4: Handle Cloud-Only Files + +Some files may show a **cloud icon** (download arrow) in Finder, meaning they +exist only in iCloud and have no local copy. Before you can move them: + +1. In Finder, right-click the file and select **"Download Now"**. +2. Alternatively, double-click the file to trigger a download. +3. To download everything at once: select all files (Cmd+A), right-click, and choose **"Download Now"**. + +Wait for all downloads to complete before moving files. Check that files have +a solid icon (no cloud symbol) before proceeding. + +## Step 5: Verify Restoration + +After moving files back: + +```bash +# Confirm files are on local disk +ls -la ~/Desktop/ + +# Confirm they are real files, not iCloud stubs +# Files with "com.apple.icloud" extended attributes are still cloud-only +xattr -l ~/Desktop/* 2>/dev/null | grep -c "com.apple.icloud" +# Should return 0 if all files are fully local +``` + +## Caveats + +1. **Download time**: If you had many files or large files in iCloud, downloading + them all back to local storage can take significant time depending on your + internet connection. Do not interrupt this process. + +2. **Storage requirements**: You need enough free local disk space to hold all the + files that were previously offloaded to iCloud. Check available space with + Apple menu > About This Mac > More Info > Storage. + +3. **Multiple Macs**: If you had Desktop & Documents sync enabled on multiple + Macs, you may see multiple Desktop folders in iCloud Drive (e.g., + "Desktop - MacBook Pro"). Each Mac's files are in its own folder. + +4. **iCloud Drive Archive**: If you turn off iCloud Drive entirely (not just the + Desktop & Documents toggle), macOS creates an "iCloud Drive (Archive)" folder + in your home directory containing a local copy of everything. This is a + separate, more drastic action. + +5. **Optimize Mac Storage**: If "Optimize Mac Storage" was enabled (System + Settings > Apple Account > iCloud > Drive), older files may have been + evicted from local storage entirely. These must be downloaded from iCloud + before they can be moved to local Desktop. + +6. **Do not sign out of iCloud** until all files are downloaded and verified + locally. Signing out may remove access to cloud-only files. + +7. **Time Machine**: If you had Time Machine backups from before enabling iCloud + sync, your original local Desktop files may also be recoverable from there. + +## Quick Reference: Key Paths + +| Location | Path | +|---|---| +| Local Desktop | `~/Desktop/` | +| Local Documents | `~/Documents/` | +| iCloud Drive Desktop | `~/Library/Mobile Documents/com~apple~CloudDocs/Desktop/` | +| iCloud Drive Documents | `~/Library/Mobile Documents/com~apple~CloudDocs/Documents/` | +| iCloud Drive Archive (if created) | `~/iCloud Drive (Archive)/` | + +## Sources + +- [Add your Desktop and Documents files to iCloud Drive - Apple Support](https://support.apple.com/en-us/109344) +- [How to find your Documents and Desktop folder contents after disabling iCloud sync - Macworld](https://www.macworld.com/article/232792/how-to-find-your-documents-and-desktop-folder-contents-after-disabling-icloud-sync.html) +- [What Happens When You Turn Off Desktop & Documents Folders for iCloud Drive - MacMost](https://macmost.com/what-happens-when-you-turn-off-desktop-documents-folders-for-icloud-drive.html) +- [How to stop desktop files from syncing to iCloud on macOS - XDA](https://www.xda-developers.com/how-stop-desktop-syncing-icloud-macos/) +- [Apple ID Renamed to Apple Account in Latest Operating System Releases](https://austinmacworks.com/apple-id-renamed-to-apple-account-in-latest-operating-system-releases/) diff --git a/docs/plans/blog-platform.md b/docs/plans/blog-platform.md new file mode 100644 index 0000000..a363b3a --- /dev/null +++ b/docs/plans/blog-platform.md @@ -0,0 +1,496 @@ +# blog.openadapt.ai Planning Document + +**Date**: 2026-03-03 +**Status**: Draft +**Author**: OpenAdapt Team + +--- + +## 1. Goals + +1. Launch a blog at `blog.openadapt.ai` for publishing guides, tutorials, and project updates. +2. Publish the first article: "Restoring macOS Desktop Folder from iCloud." +3. Attach OpenAdapt recordings to articles so readers can replay or automate the described tasks. +4. Integrate with the existing OpenAdapt automation stack (herald for social media distribution, crier for Telegram-based approval). +5. Keep costs near zero, hosting open-source-friendly, and the workflow developer-native (Git, Markdown, PRs). + +--- + +## 2. Platform Comparison + +### 2.1 Hugo + Cloudflare Pages (RECOMMENDED) + +| Aspect | Detail | +|---|---| +| **Generator** | Hugo (Go-based, fastest SSG, sub-second builds for thousands of pages) | +| **Hosting** | Cloudflare Pages free tier: unlimited bandwidth, unlimited sites, 500 builds/month, custom domains, free SSL, global CDN | +| **Content format** | Markdown files in a Git repo (e.g., `OpenAdaptAI/blog.openadapt.ai`) | +| **Theme** | PaperMod, Blowfish, or Congo -- all actively maintained, minimal, fast | +| **Deploy** | Push to `main` triggers Cloudflare Pages build automatically via GitHub integration | +| **Custom domain** | CNAME `blog.openadapt.ai` pointing to `.pages.dev`; Cloudflare handles SSL | +| **Cost** | $0/month (within free tier for any reasonable traffic) | + +**Pros**: +- Zero ongoing cost. +- Content is plain Markdown in Git -- anyone can contribute via PR. +- Hugo's template system supports custom shortcodes for embedding recordings. +- Cloudflare Pages provides unlimited bandwidth, no throttling, and edge caching worldwide. +- Build/deploy takes seconds. Preview deploys for every PR branch. +- Full ownership of content (no vendor lock-in). +- Hugo has the largest theme ecosystem of any SSG. + +**Cons**: +- No built-in CMS UI (authors must be comfortable with Git/Markdown, or use a headless CMS layer like Decap CMS). +- Hugo's Go templating syntax has a learning curve for custom layouts. +- No built-in newsletter/subscription (would need a separate service). + +### 2.2 Astro + Cloudflare Pages + +| Aspect | Detail | +|---|---| +| **Generator** | Astro (JS/TS-based, Islands architecture, zero JS by default) | +| **Hosting** | Same Cloudflare Pages free tier | +| **Content format** | MDX files with Content Collections (type-safe frontmatter) | +| **Cost** | $0/month | + +**Pros**: +- MDX allows embedding React/Vue/Svelte components directly in posts (useful for interactive recording viewers). +- Content Collections provide schema validation for frontmatter. +- Modern developer experience (TypeScript, JSX). +- Growing ecosystem with strong blog starter templates (AstroPaper, Stablo). + +**Cons**: +- Slower builds than Hugo (still fast, but measurably slower for large sites). +- Heavier toolchain (Node.js, npm/pnpm). +- Newer ecosystem; fewer themes and community resources than Hugo. +- Component islands add complexity if you just want static Markdown content. + +### 2.3 Ghost (Self-Hosted) + +| Aspect | Detail | +|---|---| +| **Platform** | Ghost CMS (Node.js, full CMS with editor, membership, newsletters) | +| **Hosting** | Self-hosted on a $5-10/month VPS (DigitalOcean, Hetzner) or $4/month on Coolify/Dokku | +| **Cost** | ~$5-10/month for VPS + maintenance time | + +**Pros**: +- Rich WYSIWYG editor (non-technical contributors can write without Git). +- Built-in newsletter/membership system. +- SEO tools, social cards, and analytics built in. +- Beautiful default themes. + +**Cons**: +- Ongoing server maintenance (updates, backups, SSL renewal if not using Cloudflare). +- $5-10/month ongoing cost. +- Content lives in a database, not in Git (harder to version, review via PR, or automate). +- Embedding custom recording widgets requires Ghost theme customization. +- Overkill for a project that values simplicity and developer tooling. + +### 2.4 Ghost (Managed / Ghost Pro) + +| Aspect | Detail | +|---|---| +| **Platform** | Ghost Pro managed hosting | +| **Cost** | $15/month (Starter), $29/month (Publisher) | + +**Pros**: +- Zero maintenance -- Ghost team handles updates, backups, scaling. +- All Ghost features out of the box. + +**Cons**: +- $180-348/year for a blog that may publish monthly. +- Same content-in-database problem as self-hosted Ghost. +- Custom domain setup is straightforward but adds vendor dependency. + +### 2.5 Substack (Custom Domain) + +| Aspect | Detail | +|---|---| +| **Platform** | Substack newsletter/blog platform | +| **Custom domain** | One-time $50 fee; must use `www.blog.openadapt.ai` prefix | +| **Cost** | Free (Substack takes 10% of paid subscription revenue, if any) | + +**Pros**: +- Zero maintenance. +- Built-in email delivery, subscriber management, discovery network. +- Simple writing interface. + +**Cons**: +- Very limited customization (no custom CSS, no shortcodes, no embedded widgets). +- Cannot embed OpenAdapt recordings or interactive content. +- Content is locked inside Substack's platform (export is possible but lossy). +- Loses Substack discovery network benefit with custom domain. +- `www.` prefix requirement is awkward for `blog.openadapt.ai`. +- No Git-based workflow; not developer-friendly. +- 10% revenue cut if paid subscriptions are ever enabled. + +### 2.6 Next.js Custom Blog + +| Aspect | Detail | +|---|---| +| **Framework** | Next.js (the same framework powering openadapt.ai) | +| **Hosting** | Vercel free tier or Cloudflare Pages | +| **Cost** | $0/month | + +**Pros**: +- Consistent tech stack with the main openadapt.ai site. +- Maximum flexibility for custom recording viewer components. +- MDX support with full React component embedding. + +**Cons**: +- Must build everything from scratch (layouts, RSS, sitemap, SEO, pagination). +- Highest development effort of all options. +- Overkill unless the blog needs heavy interactivity on every page. + +--- + +## 3. Recommendation: Hugo + Cloudflare Pages + +**Hugo + Cloudflare Pages** is the recommended platform for the following reasons: + +1. **Cost**: $0/month. Cloudflare Pages free tier has no bandwidth limits, no build minute caps that matter for a blog, and free SSL on custom domains. + +2. **Simplicity**: Content is Markdown in a Git repo. Publishing is `git push`. Reviewing drafts is a GitHub PR. This fits the open-source, developer-native workflow the project already uses across all `openadapt-*` repos. + +3. **Speed**: Hugo builds in milliseconds. Cloudflare's edge network serves pages with sub-50ms TTFB globally. + +4. **Extensibility**: Hugo shortcodes allow embedding custom HTML/JS widgets for OpenAdapt recordings without touching the theme. Example: `{{}}` could render an interactive recording viewer. + +5. **No vendor lock-in**: Content is plain Markdown files. Migrating to any other SSG (Astro, Jekyll, 11ty) would require minimal effort. + +6. **Mature ecosystem**: Hugo has hundreds of themes, well-documented shortcode and partial systems, and a large community. + +7. **Herald/crier compatibility**: Since content lives in Git, herald can detect new blog posts via GitHub releases or merged PRs (its existing `collect` pipeline) and compose social media announcements. No API integration needed -- the Git history IS the API. + +### Why not Astro? + +Astro is a strong alternative and would be the second choice. It offers better component embedding via MDX. However, for a blog that primarily publishes Markdown articles with occasional embedded recordings, Hugo's simplicity and build speed win. If the blog evolves to need heavy interactive content on every page, migrating to Astro is straightforward since content is already Markdown. + +### Why not Ghost? + +Ghost's WYSIWYG editor is nice, but the project's contributors are developers comfortable with Markdown and Git. The database-backed content model conflicts with the PR-based review workflow used everywhere else. The ongoing hosting cost, while small, is nonzero and adds maintenance burden. + +--- + +## 4. DNS / Domain Setup + +### Prerequisites +- Access to the DNS provider managing `openadapt.ai` (the main site uses Next.js with static export; the DNS provider needs to be identified -- likely wherever the `.ai` domain was registered). + +### Steps + +1. **Create Cloudflare Pages project**: + ```bash + # In the blog repo after initial Hugo setup + # Connect via Cloudflare dashboard: Pages > Create project > Connect to Git + # Build command: hugo --minify + # Build output directory: public + # Environment variable: HUGO_VERSION = 0.145.0 + ``` + +2. **Add custom domain in Cloudflare Pages**: + - Go to the Pages project > Custom domains > Add `blog.openadapt.ai`. + - Cloudflare will provide a CNAME target (e.g., `.pages.dev`). + +3. **Add CNAME record at the DNS provider**: + ``` + blog.openadapt.ai. CNAME .pages.dev. + ``` + - If the domain DNS is already on Cloudflare, this is automatic. + - If DNS is elsewhere (e.g., the `.ai` registrar), add the CNAME record there. + +4. **SSL**: Cloudflare Pages issues a free SSL certificate automatically once the CNAME is verified. No additional setup needed. + +5. **Verification**: After DNS propagation (minutes to hours), `https://blog.openadapt.ai` will serve the Hugo site. + +--- + +## 5. Repository Structure + +Create a new repo: `OpenAdaptAI/blog.openadapt.ai` + +``` +blog.openadapt.ai/ + archetypes/ + default.md # Template for new posts + assets/ + css/ + custom.css # Minor style overrides + content/ + posts/ + 2026-03-restore-macos-desktop-icloud/ + index.md # Article content + images/ # Post-specific images + recordings/ # OpenAdapt recording files (.json, .zip) + _index.md # Blog listing page + data/ + recordings/ # Shared recording metadata (optional) + layouts/ + shortcodes/ + recording.html # {{}} shortcode + recording-step.html # {{}} + static/ + recordings/ # Compiled recording viewer assets (JS/CSS) + config/ + _default/ + hugo.toml # Site config + menus.toml # Navigation + params.toml # Theme parameters + .github/ + workflows/ + deploy.yml # (Optional) CI checks; Cloudflare Pages auto-deploys + notify-herald.yml # Trigger herald on new post merge + README.md +``` + +### Content frontmatter convention + +```yaml +--- +title: "Restoring macOS Desktop Folder from iCloud" +date: 2026-03-05 +draft: false +tags: ["macos", "icloud", "desktop", "recovery"] +categories: ["Guides"] +recording_id: "abc123de" # OpenAdapt recording ID +recording_steps: 15 # Number of steps in the recording +summary: > + Step-by-step guide to recovering your Desktop folder after + iCloud Drive sync issues, with an OpenAdapt recording you + can replay to automate the fix. +--- +``` + +--- + +## 6. OpenAdapt Recording Integration + +### 6.1 What a recording contains + +An OpenAdapt recording consists of: +- A sequence of before/after screenshot pairs (PNGs). +- Action metadata (click coordinates, keystrokes, text input). +- A textual step-by-step description (generated by VLM annotation). + +### 6.2 Embedding approach + +**Option A -- Static image gallery with step descriptions (recommended for v1)**: + +Create a Hugo shortcode that renders a scrollable step-by-step viewer: + +```html + +{{ $id := .Get "id" }} +{{ $dir := printf "recordings/%s" $id }} +
+

OpenAdapt Recording

+

+ This guide includes an OpenAdapt recording. You can view each + step below or download + the full recording to replay it with OpenAdapt. +

+
+ {{ range $i, $step := (index $.Page.Params "recording_steps_data") }} +
+
Step {{ add $i 1 }}
+ Step {{ add $i 1 }} +

{{ $step.description }}

+
+ {{ end }} +
+
+``` + +Usage in a post: + +```markdown +Follow the steps below, or use the OpenAdapt recording to automate them: + +{{}} +``` + +**Option B -- Interactive JS viewer (future enhancement)**: + +Build a small JavaScript widget (could live in `openadapt-viewer` repo) that: +- Loads recording JSON from a URL. +- Renders a before/after screenshot slider for each step. +- Shows action overlays (click targets, drag paths). +- Provides a "Download & Replay with OpenAdapt" button. + +This widget would be built once and included as a static asset in the blog. + +### 6.3 Recording file hosting + +Recordings contain many PNGs and can be large. Options: + +| Approach | Pros | Cons | +|---|---|---| +| **Git LFS in blog repo** | Simple, versioned | LFS bandwidth limits on GitHub free tier (1GB/month) | +| **GitHub Releases** | Free, no bandwidth limits for public repos | Less convenient to reference from Hugo | +| **Cloudflare R2** | Free egress, 10GB free storage | Requires R2 bucket setup | +| **Inline in post directory** | Simplest Hugo integration | Bloats Git repo | + +**Recommendation**: For v1, store compressed recording bundles (`.zip`) as GitHub Release assets on the blog repo, and include only the key screenshots (2-4 per recording) directly in the post directory. Link to the full recording download from the release. + +--- + +## 7. Content Workflow + +### 7.1 Writing and review + +``` +Author writes post Pushes branch PR review Merge to main +in Markdown --> to GitHub --> (text + images) --> triggers deploy + via GitHub PR +``` + +1. **Create branch**: `git checkout -b post/restore-macos-desktop` +2. **Write content**: Edit `content/posts/2026-03-restore-macos-desktop-icloud/index.md` +3. **Add images**: Place screenshots in the post's `images/` subdirectory. +4. **Attach recording**: Reference recording ID in frontmatter; include key screenshots. +5. **Preview locally**: `hugo server -D` (serves at `localhost:1313` with live reload). +6. **Push and open PR**: Standard GitHub PR. Cloudflare Pages creates a preview deploy on the PR branch (e.g., `https://.blog-openadapt-ai.pages.dev`). +7. **Review**: Team reviews content, screenshots, recording references. +8. **Merge**: Squash-merge to `main`. Cloudflare Pages auto-deploys to `blog.openadapt.ai`. + +### 7.2 Social media distribution via herald + +When a new post merges to `main`, trigger herald to announce it: + +**Option A -- GitHub Actions workflow (recommended)**: + +```yaml +# .github/workflows/notify-herald.yml +name: Announce new blog post +on: + push: + branches: [main] + paths: ['content/posts/**'] + +jobs: + announce: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Detect new post + id: detect + run: | + NEW_POST=$(git diff --name-only HEAD~1 HEAD -- content/posts/ \ + | grep 'index.md' | head -1) + if [ -n "$NEW_POST" ]; then + TITLE=$(grep '^title:' "$NEW_POST" | sed 's/title: *"//;s/"$//') + SLUG=$(dirname "$NEW_POST" | xargs basename) + echo "title=$TITLE" >> "$GITHUB_OUTPUT" + echo "url=https://blog.openadapt.ai/posts/$SLUG/" >> "$GITHUB_OUTPUT" + echo "found=true" >> "$GITHUB_OUTPUT" + fi + + - name: Announce via herald + if: steps.detect.outputs.found == 'true' + run: | + pip install herald-announce + herald publish \ + --content-type spotlight \ + --title "${{ steps.detect.outputs.title }}" \ + --url "${{ steps.detect.outputs.url }}" \ + --repos OpenAdaptAI/blog.openadapt.ai + env: + HERALD_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + HERALD_DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + HERALD_TWITTER_CONSUMER_KEY: ${{ secrets.TWITTER_CONSUMER_KEY }} + HERALD_TWITTER_CONSUMER_SECRET: ${{ secrets.TWITTER_CONSUMER_SECRET }} + HERALD_TWITTER_ACCESS_TOKEN: ${{ secrets.TWITTER_ACCESS_TOKEN }} + HERALD_TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} + HERALD_LINKEDIN_ACCESS_TOKEN: ${{ secrets.LINKEDIN_ACCESS_TOKEN }} +``` + +**Option B -- Crier watches the blog repo**: + +Add `OpenAdaptAI/blog.openadapt.ai` to crier's `CRIER_REPOS` list. Crier will detect merged PRs that add new posts, score them for interest, draft announcements, and send them to Telegram for approval before posting via herald. + +This is the more hands-off approach: crier handles the "should we post?" decision and human review loop automatically. + +### 7.3 Recommended combined approach + +Use **both**: +- **Crier** as the default, always-on watcher. It monitors the blog repo alongside all other OpenAdapt repos. New posts automatically trigger draft announcements for Telegram approval. +- **Herald direct** (via the GitHub Actions workflow) as a manual fallback or for urgent announcements that should bypass the interest-scoring step. + +--- + +## 8. Cost Summary + +| Component | Monthly Cost | +|---|---| +| Cloudflare Pages hosting | $0 | +| Custom domain DNS (CNAME) | $0 (part of existing domain) | +| SSL certificate | $0 (Cloudflare automatic) | +| Hugo (open source) | $0 | +| GitHub repo (public) | $0 | +| Herald social media posting | $0 (uses existing API keys) | +| LLM calls for herald/crier | ~$0.01-0.05 per announcement | +| Recording file hosting (GitHub Releases) | $0 | +| **Total** | **~$0/month** | + +--- + +## 9. Implementation Roadmap + +### Phase 1: Foundation (1-2 days) + +- [ ] Create `OpenAdaptAI/blog.openadapt.ai` GitHub repo. +- [ ] Initialize Hugo project with a clean theme (PaperMod or Congo). +- [ ] Configure `hugo.toml` with site metadata, OpenAdapt branding. +- [ ] Set up Cloudflare Pages project connected to the repo. +- [ ] Add CNAME record for `blog.openadapt.ai`. +- [ ] Verify the site is live at `https://blog.openadapt.ai`. + +### Phase 2: First Post (1 day) + +- [ ] Write "Restoring macOS Desktop Folder from iCloud" article. +- [ ] Record the task with OpenAdapt (local demo via openadapt-capture or manual screenshots). +- [ ] Create the `recording` shortcode for embedding step-by-step screenshots. +- [ ] Add recording screenshots and ZIP to the post. +- [ ] Publish via PR merge. + +### Phase 3: Herald/Crier Integration (1 day) + +- [ ] Add `notify-herald.yml` GitHub Actions workflow to the blog repo. +- [ ] Add `OpenAdaptAI/blog.openadapt.ai` to crier's watched repos. +- [ ] Test the end-to-end flow: merge post PR, verify crier sends Telegram draft, approve, verify social media posts. + +### Phase 4: Enhancements (ongoing) + +- [ ] Build interactive recording viewer widget (JS-based before/after slider). +- [ ] Add RSS feed (Hugo generates this automatically with most themes). +- [ ] Add "Run with OpenAdapt" button that deep-links to the OpenAdapt desktop app or web installer. +- [ ] Add optional Decap CMS (formerly Netlify CMS) for non-developer contributors. +- [ ] Consider Cloudflare R2 for large recording file hosting if GitHub Release approach hits limits. +- [ ] Add search (Pagefind -- a lightweight static search index that integrates well with Hugo). + +--- + +## 10. Alternative Hosting: Netlify or Vercel + +If Cloudflare Pages is not preferred for any reason, both Netlify and Vercel offer comparable free tiers: + +| Provider | Free Tier Limits | Custom Domain | Build Minutes | +|---|---|---|---| +| Cloudflare Pages | Unlimited bandwidth, 500 builds/mo | Yes, free SSL | 500/mo | +| Netlify | 100GB bandwidth/mo, 300 build minutes/mo | Yes, free SSL | 300/mo | +| Vercel | 100GB bandwidth/mo | Yes, free SSL | 6000 min/mo | +| GitHub Pages | 100GB bandwidth/mo, 10 builds/hr | Yes (CNAME), free SSL | N/A (Actions minutes) | + +Cloudflare Pages is recommended due to unlimited bandwidth and the best CDN performance, but any of these would work. + +--- + +## 11. Open Questions + +1. **DNS provider**: Where is `openadapt.ai` currently registered, and who manages DNS? This determines how to add the `blog` CNAME record. +2. **OpenAdapt recording format**: Is there a standardized export format for recordings that the blog widget should consume? If not, define one. +3. **Non-developer contributors**: Will non-developers need to write blog posts? If yes, adding Decap CMS (a Git-backed headless CMS with a web UI) should be prioritized in Phase 4. +4. **Newsletter**: Is email newsletter distribution desired? If so, a service like Buttondown ($0 for <100 subscribers) or Mailchimp free tier could be added, with a subscription form embedded in the blog. diff --git a/docs/plans/cli-tool.md b/docs/plans/cli-tool.md new file mode 100644 index 0000000..112bd10 --- /dev/null +++ b/docs/plans/cli-tool.md @@ -0,0 +1,533 @@ +# Wright CLI Tool — Design Plan + +## Motivation + +Wright currently requires Telegram to submit tasks and monitor progress. This creates friction for developers who want to stay in the terminal. A CLI tool provides a first-class developer experience: submit jobs, stream logs, and run the dev loop locally — all without leaving the shell. + +The CLI becomes the **third entry point** into Wright, alongside the Telegram bot and the Herald webhook integration. All three share the same Supabase queue and worker infrastructure. + +## 1. What the CLI Does + +### Commands + +| Command | Description | +|---------|-------------| +| `wright task ` | Submit a job to the Supabase queue (same as `/task` in Telegram) | +| `wright status [job_id]` | Show job status, recent events, cost. Without `job_id`, shows the most recent job. | +| `wright logs ` | Stream events in real-time via Supabase Realtime (like `fly logs` or `kubectl logs -f`) | +| `wright cancel ` | Cancel a queued or running job | +| `wright list` | List recent jobs (default: 10, with `--limit` flag) | +| `wright local ` | Run the dev loop locally — no Supabase, no worker, clone + Claude + test + PR directly | +| `wright config` | Show or set configuration (Supabase URL, keys, defaults) | + +### Command Details + +#### `wright task` + +``` +wright task [options] + +Options: + --branch Base branch (default: main) + --max-loops Max edit-test-fix loops (default: 10) + --budget Max API spend in USD (default: 5.00) + --follow After submitting, immediately tail logs (like `wright logs`) + --json Output job object as JSON (for scripting) +``` + +Submits a job to Supabase's `job_queue` table. The `telegram_chat_id` and `telegram_message_id` fields are set to `null` (no Telegram notification). The GitHub token comes from `GITHUB_TOKEN` env var or `~/.config/wright/config.json`. + +When `--follow` is passed, after successful submission the CLI automatically enters the `logs` streaming mode for that job (same as `wright task ... && wright logs `). + +#### `wright status` + +``` +wright status [job_id] [options] + +Options: + --json Output as JSON +``` + +If `job_id` is omitted, fetches the most recent job created by this user (requires storing a local "my jobs" cache or filtering by some user identifier — see Authentication section). Displays: + +- Job ID (short), status, repo URL, task description +- Cost so far, loops completed +- PR URL (if created) +- Last 5 events with timestamps + +#### `wright logs` + +``` +wright logs [options] + +Options: + --since Only show events after this time + --no-follow Fetch current events and exit (no streaming) +``` + +Opens a Supabase Realtime subscription on `job_events` filtered to the given `job_id`. Renders events as a live-updating terminal log: + +``` +[12:34:01] CLAIMED Job claimed by worker +[12:34:05] CLONED Repository cloned (jest, npm) +[12:34:10] LOOP_START Dev loop iteration 1/10 +[12:34:45] EDIT Files edited: src/utils.ts (200 chars) +[12:34:50] TEST_RUN Running tests... +[12:34:58] TEST_FAIL 3 passed, 1 failed +[12:35:02] LOOP_START Dev loop iteration 2/10 +... +[12:36:30] TEST_PASS 4 passed, 0 failed +[12:36:35] PR_CREATED https://github.com/org/repo/pull/42 +[12:36:36] COMPLETED Success (2 loops, $0.38) +``` + +Automatically exits when the job reaches a terminal state (`succeeded` or `failed`). Uses Supabase Realtime `postgres_changes` — the same mechanism the bot already uses in `subscribeToJobEvents()`. + +#### `wright cancel` + +``` +wright cancel +``` + +Sets the job status to `failed` with error `"Cancelled by user via CLI"`. Same logic as `cancelJob()` in `apps/bot/src/supabase.ts`. Only works on `queued`, `claimed`, or `running` jobs. + +#### `wright list` + +``` +wright list [options] + +Options: + --limit Number of jobs to show (default: 10) + --status Filter by status (queued, running, succeeded, failed) + --json Output as JSON +``` + +Fetches recent jobs from `job_queue`, ordered by `created_at DESC`. Renders as a table: + +``` +ID STATUS REPO TASK COST AGE +a1b2c3d4 succeeded github.com/org/repo Fix login button $0.38 2h ago +e5f6g7h8 running github.com/org/other Add rate limiting $1.22 15m ago +i9j0k1l2 failed github.com/org/lib Update docs $0.05 1d ago +``` + +#### `wright local` + +``` +wright local [options] + +Options: + --branch Base branch (default: main) + --max-loops Max edit-test-fix loops (default: 10) + --budget Max API spend (default: 5.00) + --model Claude model (default: claude-sonnet-4-20250514) + --workdir Working directory (default: /tmp/wright-work/) + --keep-workdir Don't delete the work directory after completion + --dry-run Clone and detect, but don't run Claude +``` + +Runs the full dev loop **locally** on the developer's machine. No Supabase required. No worker. The CLI directly calls `runDevLoop()` from `apps/worker/src/dev-loop.ts`, with events printed to stdout instead of inserted into Supabase. + +This is the **power-user mode** — useful for: +- Development/testing of Wright itself +- Running without Supabase infrastructure +- Debugging job failures by re-running locally +- CI/CD pipelines that want to use Wright as a library + +#### `wright config` + +``` +wright config # Show current config +wright config set # Set a config value +wright config get # Get a config value +``` + +Configuration stored in `~/.config/wright/config.json`: + +```json +{ + "supabase_url": "https://xyz.supabase.co", + "supabase_key": "eyJ...", + "github_token": "ghp_...", + "default_branch": "main", + "default_max_loops": 10, + "default_budget_usd": 5.0, + "default_model": "claude-sonnet-4-20250514" +} +``` + +Environment variables override config file values. Precedence: `env > config file > defaults`. + +## 2. Architecture Decisions + +### 2.1 Package Location: `apps/cli/` + +The CLI goes in `apps/cli/`, not `packages/cli/`. Rationale: + +- **`apps/` is for deployable/runnable entry points** (worker, bot, cli). The CLI is a standalone executable. +- **`packages/` is for shared libraries** (shared types/constants). The CLI is not imported by other packages. +- Consistent with the existing monorepo convention: `apps/worker` is the Fly.io worker, `apps/bot` is the Telegram bot, `apps/cli` is the terminal interface. + +The pnpm workspace already includes `apps/*`, so `apps/cli` is automatically discovered. + +### 2.2 CLI Framework: Commander.js + +**Recommendation: Commander.js** + +| Framework | Pros | Cons | +|-----------|------|------| +| **Commander.js** | Minimal, well-known, zero config, <50KB. Perfect for a tool with 7 commands. | No plugin system (not needed here). | +| oclif | Plugin architecture, auto-generated help, testing utils. | Heavy (100+ deps), designed for CLIs with 50+ commands. Over-engineered for 7 commands. | +| yargs | Powerful argument parsing, middleware. | More complex API than Commander for simple command trees. | +| Custom (process.argv) | Zero deps. | Reinventing argument parsing, help text, validation. | + +Commander.js is the right choice because: +- Wright CLI has 7 commands with simple argument structures +- Commander gives us subcommands, `--help` generation, option parsing, and validation out of the box +- It is the most commonly used Node.js CLI framework (50k+ GitHub stars, used by `vue-cli`, `create-react-app`, etc.) +- The entire CLI framework adds ~40KB, while oclif would add megabytes of scaffolding we don't need + +### 2.3 Authentication + +The CLI needs three credentials: + +| Credential | Purpose | Source (precedence order) | +|------------|---------|--------------------------| +| `SUPABASE_URL` | Connect to job queue | env > config file | +| `SUPABASE_KEY` | Authenticate to Supabase | env > config file | +| `GITHUB_TOKEN` | Push branches, create PRs | env > config file > `gh auth token` fallback | +| `ANTHROPIC_API_KEY` | Claude API (local mode only) | env > config file | + +**Key type for Supabase**: The CLI should use the **anon key** (same as the bot), not the service role key. The anon key, combined with Supabase Row Level Security (RLS), is safe to distribute. The service role key bypasses RLS and should only live on the worker. + +However, the current schema does not have RLS policies. Until RLS is implemented, the CLI will use `SUPABASE_KEY` which can be either anon or service role. The key is stored in `~/.config/wright/config.json` (file permissions `0600`) or passed via environment variable. + +**GitHub token**: The CLI first checks `GITHUB_TOKEN` env var, then config file, then attempts `gh auth token` as a fallback (if the user has GitHub CLI installed and authenticated). This gives a zero-config experience for developers who already use `gh`. + +**`wright local` mode credentials**: Only needs `GITHUB_TOKEN` and `ANTHROPIC_API_KEY`. No Supabase at all. + +### 2.4 Real-time Event Streaming + +For `wright logs`, the CLI uses Supabase Realtime — the same mechanism already used by the bot in `apps/bot/src/supabase.ts`: + +``` +subscribeToJobEvents(callback, jobId) → RealtimeChannel +``` + +The CLI will create its own Supabase client and subscribe to `postgres_changes` on the `job_events` table, filtered by `job_id`. Events are rendered as they arrive. + +**Connection lifecycle:** +1. CLI creates Supabase client with anon key +2. Subscribes to `job_events` INSERT events for the given `job_id` +3. Also subscribes to `job_queue` UPDATE events for the same job (to detect terminal states) +4. On terminal state (`succeeded` or `failed`), unsubscribe and exit +5. On Ctrl+C, unsubscribe and exit cleanly + +**Fallback for no-Realtime environments**: If Realtime fails to connect (e.g., Supabase plan doesn't include it), fall back to polling `getJobEvents()` every 2 seconds. The CLI should detect this automatically and log a warning. + +### 2.5 Local Mode (`wright local`) + +The local mode reuses `runDevLoop()` from `apps/worker/src/dev-loop.ts`. However, `runDevLoop` currently requires a Supabase client for event emission and test result storage. The local mode needs to work without Supabase. + +**Approach: Event adapter pattern** + +Create an `EventSink` interface in `@wright/shared`: + +```typescript +interface EventSink { + emit(jobId: string, eventType: string, loopNumber?: number, payload?: Record): Promise + storeTestResults(jobId: string, loopNumber: number, results: TestResults): Promise +} +``` + +Two implementations: +1. **`SupabaseEventSink`** — the current behavior, inserts into `job_events` and `test_results` tables (used by the worker) +2. **`ConsoleEventSink`** — prints events to stdout with timestamps and colors (used by `wright local`) + +`runDevLoop` currently takes `DevLoopConfig` which includes `supabaseUrl` and `supabaseServiceKey`. Refactor to accept an `EventSink` instead. This is a non-breaking change: the worker constructs a `SupabaseEventSink`, the CLI constructs a `ConsoleEventSink`. + +**Synthetic Job object**: `wright local` creates a `Job` object in memory (no database insert): + +```typescript +const job: Job = { + id: crypto.randomUUID(), + repo_url: repoUrl, + branch: options.branch ?? 'main', + task: description, + max_loops: options.maxLoops ?? DEFAULT_MAX_LOOPS, + max_budget_usd: options.budget ?? DEFAULT_MAX_BUDGET_USD, + status: 'running', + total_cost_usd: 0, + attempt: 1, + max_attempts: 1, + github_token: resolveGithubToken(), + created_at: new Date().toISOString(), +} +``` + +## 3. Package Details + +### Name and Publishing + +- **Internal package name**: `@wright/cli` +- **npm publish name**: `openadapt-wright` (the `wright` name is likely taken on npm) +- **Binary name**: `wright` (via `package.json` `"bin"` field) + +```json +{ + "name": "@wright/cli", + "bin": { + "wright": "./dist/cli.js" + } +} +``` + +Users install with: +```bash +npm install -g openadapt-wright +# or +npx openadapt-wright task https://github.com/org/repo "Fix the bug" +``` + +### Dependencies + +```json +{ + "dependencies": { + "@wright/shared": "workspace:*", + "@supabase/supabase-js": "^2.49.0", + "commander": "^13.0.0", + "chalk": "^5.4.0" + } +} +``` + +- **`@wright/shared`**: Types, constants (Job, JobEvent, TABLES, JOB_STATUS, etc.) +- **`@supabase/supabase-js`**: Supabase client for remote commands (task, status, cancel, list, logs) +- **`commander`**: CLI framework +- **`chalk`**: Terminal colors for formatted output (status indicators, event types, etc.) + +For the `local` command, the CLI additionally needs the worker's dev-loop module. Two options: + +1. **Import from `@wright/worker`** (requires exposing `runDevLoop` as a package export) +2. **Extract dev-loop into `@wright/core`** (a new shared package) + +Option 1 is simpler for Phase 3 (just add `"@wright/worker": "workspace:*"` and ensure `runDevLoop` is exported). Option 2 is cleaner architecturally but adds a new package to maintain. Recommendation: start with Option 1, refactor to Option 2 only if the dependency graph becomes awkward. + +## 4. Implementation Phases + +### Phase 1: Core Commands (task, status, cancel, list) + +**Scope**: Submit jobs, query status, cancel, and list — all via Supabase REST (no Realtime needed). + +**Files to create**: +- `apps/cli/package.json` +- `apps/cli/tsconfig.json` +- `apps/cli/src/cli.ts` — entry point, Commander setup, command registration +- `apps/cli/src/commands/task.ts` — submit job to Supabase +- `apps/cli/src/commands/status.ts` — fetch and display job status +- `apps/cli/src/commands/cancel.ts` — cancel a job +- `apps/cli/src/commands/list.ts` — list recent jobs +- `apps/cli/src/commands/config.ts` — show/set configuration +- `apps/cli/src/lib/supabase.ts` — Supabase client (reuse patterns from bot's supabase.ts) +- `apps/cli/src/lib/config.ts` — config file read/write (~/.config/wright/config.json) +- `apps/cli/src/lib/format.ts` — terminal formatting (table renderer, status colors, time ago) +- `apps/cli/src/lib/auth.ts` — credential resolution (env > config > gh auth token) + +**Files to modify**: +- `pnpm-workspace.yaml` — already includes `apps/*`, no change needed +- Root `package.json` — add `"cli": "pnpm --filter @wright/cli"` script (optional convenience) + +**Estimated effort**: 2-3 days + +**Exit criteria**: `wright task`, `wright status`, `wright cancel`, `wright list` all work against the production Supabase instance. Jobs submitted via CLI are picked up by the Fly.io worker and visible in the Telegram bot. + +### Phase 2: Real-time Log Streaming (logs) + +**Scope**: `wright logs ` streams events live via Supabase Realtime. + +**Files to create**: +- `apps/cli/src/commands/logs.ts` — Realtime subscription, formatted event rendering + +**Files to modify**: +- `apps/cli/src/commands/task.ts` — add `--follow` flag that chains into `logs` after submission + +**Key considerations**: +- Supabase Realtime requires WebSocket support. The `@supabase/supabase-js` client handles this natively in Node.js. +- Need graceful cleanup on Ctrl+C (unsubscribe from channel, close WebSocket). +- Polling fallback if Realtime is unavailable. +- Back-pressure: events arrive faster than they can be rendered? Unlikely for dev-loop events (max ~1/sec), but buffer if needed. + +**Estimated effort**: 1-2 days + +**Exit criteria**: `wright logs ` shows live events as the worker processes a job. Automatically exits on completion. `wright task --follow` submits and streams in one command. + +### Phase 3: Local Mode (local) + +**Scope**: `wright local` runs the entire dev loop on the developer's machine without Supabase. + +**Files to create**: +- `apps/cli/src/commands/local.ts` — orchestrator for local mode +- `apps/cli/src/lib/console-event-sink.ts` — EventSink implementation that prints to terminal + +**Files to modify**: +- `packages/shared/src/types.ts` — add `EventSink` interface +- `apps/worker/src/dev-loop.ts` — refactor `emit()` and `supabase.from('test_results').insert()` calls to use `EventSink` interface instead of raw Supabase client +- `apps/worker/package.json` — export `runDevLoop` (add `"exports"` field or adjust `"main"`) + +**Refactoring `dev-loop.ts`**: + +Currently `runDevLoop` takes `DevLoopConfig` which has `supabaseUrl` and `supabaseServiceKey`, and internally creates a Supabase client for event emission. The refactor: + +1. Add `eventSink?: EventSink` to `DevLoopConfig` +2. If `eventSink` is provided, use it. Otherwise, construct a `SupabaseEventSink` from `supabaseUrl`/`supabaseServiceKey` (backward compatible). +3. Replace all `emit(supabase, ...)` calls with `eventSink.emit(...)` +4. Replace `supabase.from('test_results').insert(...)` with `eventSink.storeTestResults(...)` + +This refactoring is backward-compatible: the worker continues to work exactly as before by passing `supabaseUrl`/`supabaseServiceKey`. The CLI passes a `ConsoleEventSink` and omits Supabase credentials. + +**Estimated effort**: 2-3 days (includes the dev-loop refactoring) + +**Exit criteria**: `wright local https://github.com/org/repo "Fix the bug"` clones the repo, runs Claude, runs tests, creates a PR, and prints all events to the terminal. No Supabase involved. + +## 5. Key Files Summary + +### New Files + +| File | Purpose | +|------|---------| +| `apps/cli/package.json` | Package manifest with `"bin": {"wright": ...}` | +| `apps/cli/tsconfig.json` | TypeScript config extending root | +| `apps/cli/src/cli.ts` | Entry point — Commander program definition | +| `apps/cli/src/commands/task.ts` | Submit job to queue | +| `apps/cli/src/commands/status.ts` | Show job status + events | +| `apps/cli/src/commands/cancel.ts` | Cancel a job | +| `apps/cli/src/commands/list.ts` | List recent jobs | +| `apps/cli/src/commands/logs.ts` | Real-time event streaming (Phase 2) | +| `apps/cli/src/commands/local.ts` | Local dev loop execution (Phase 3) | +| `apps/cli/src/commands/config.ts` | Configuration management | +| `apps/cli/src/lib/supabase.ts` | Supabase client singleton | +| `apps/cli/src/lib/config.ts` | Config file read/write | +| `apps/cli/src/lib/format.ts` | Terminal formatting utilities | +| `apps/cli/src/lib/auth.ts` | Credential resolution | +| `apps/cli/src/lib/console-event-sink.ts` | EventSink for local mode (Phase 3) | + +### Modified Files + +| File | Change | +|------|--------| +| `packages/shared/src/types.ts` | Add `EventSink` interface (Phase 3) | +| `apps/worker/src/dev-loop.ts` | Accept `EventSink`, refactor emit calls (Phase 3) | +| `apps/worker/package.json` | Export `runDevLoop` for CLI consumption (Phase 3) | + +## 6. Tradeoffs and Recommendations + +### Supabase Anon Key vs Service Role Key + +**Tradeoff**: The anon key is safe to distribute but requires RLS policies. The service role key bypasses RLS but should never be on client machines. + +**Recommendation**: Use the anon key for the CLI. Add RLS policies to `job_queue` and `job_events` tables as part of Phase 1 setup. Minimum viable RLS: allow INSERT into `job_queue` (anyone can submit), allow SELECT on `job_queue` and `job_events` (anyone can view), restrict UPDATE/DELETE to service role (only workers can claim/complete). + +If RLS is too much for Phase 1, use the service role key with clear documentation that it should be treated as a secret. The config file uses `0600` permissions. + +### Extracting `@wright/core` vs Importing `@wright/worker` + +**Tradeoff**: Importing `runDevLoop` from `@wright/worker` means the CLI depends on the worker package, which brings in Express, simple-git, and the Claude Agent SDK as transitive dependencies. Extracting a `@wright/core` package would keep the dependency graph cleaner. + +**Recommendation**: Start with importing from `@wright/worker` in Phase 3. The transitive deps (Express, simple-git, Claude SDK) are all needed by the CLI's local mode anyway — it literally runs the same code. Extract `@wright/core` only if the worker grows features that the CLI should not depend on (e.g., Fly.io-specific scale-to-zero logic). + +### npm Package Name + +**Tradeoff**: `wright` is short but likely taken on npm. `openadapt-wright` is available and consistent with the GitHub repo name. + +**Recommendation**: Publish as `openadapt-wright` on npm. The binary name is still `wright` (via the `"bin"` field). Users type `wright task ...` regardless of the npm package name. If `wright` becomes available, we can add it as an alias later. + +### `--follow` Default Behavior + +**Tradeoff**: Should `wright task` follow by default (like `docker run` with attached stdout) or should it submit and return (like `kubectl apply`)? + +**Recommendation**: Do NOT follow by default. `wright task` should submit and return immediately with the job ID. The `--follow` flag opts into tailing. Rationale: developers may want to submit multiple jobs in quick succession, or submit from a script. Blocking by default would be surprising. The printed output will include a hint: `Run 'wright logs ' to follow progress.` + +### Config File Location + +**Tradeoff**: XDG spec says `~/.config/wright/config.json`. Some tools use `~/.wrightrc`. Others use `~/..json`. + +**Recommendation**: Follow XDG: `~/.config/wright/config.json`. Use `$XDG_CONFIG_HOME/wright/config.json` if the env var is set, otherwise `~/.config/wright/config.json`. This is the standard for modern CLI tools. + +### Terminal Output Formatting + +**Tradeoff**: Plain text vs rich formatting (colors, spinners, tables). + +**Recommendation**: Use chalk for colors and a simple table formatter. No spinners (they complicate piping and logging). Support `--json` on all commands for machine-readable output. Respect `NO_COLOR` env var per the [no-color convention](https://no-color.org/). When stdout is not a TTY (piped), disable colors automatically. + +### Testing Strategy + +**Recommendation**: Follow the same testing pattern as the worker (vitest, mocked externals). Key test cases: + +- **Command parsing**: Verify Commander parses arguments and options correctly +- **Supabase operations**: Mock Supabase client, verify correct table/column usage +- **Config file**: Test read/write/merge with env var overrides +- **Format utilities**: Test table rendering, time-ago formatting, event formatting +- **Local mode**: Reuse the worker's dev-loop test patterns with a ConsoleEventSink + +## 7. Example Usage Session + +```bash +# First-time setup +wright config set supabase_url https://xyz.supabase.co +wright config set supabase_key eyJ... +# GitHub token auto-detected from `gh auth token` + +# Submit a task +$ wright task https://github.com/OpenAdaptAI/openadapt-wright "Add input validation to the /task command" +Job queued: a1b2c3d4-e5f6-7890-abcd-ef1234567890 +Repo: https://github.com/OpenAdaptAI/openadapt-wright +Task: Add input validation to the /task command +Budget: $5.00 (10 loops max) + +Run 'wright logs a1b2c3d4' to follow progress. + +# Check status later +$ wright status a1b2c3d4 +Job a1b2c3d4 RUNNING +Repo: github.com/OpenAdaptAI/openadapt-wright +Task: Add input validation to the /task command +Cost: $0.42 +Loops: 3/10 + +Recent events: + [12:34:01] CLAIMED Job claimed by worker + [12:34:05] CLONED Repository cloned + [12:34:10] LOOP_START Dev loop iteration 1/10 + [12:34:45] EDIT Files edited + [12:34:58] TEST_FAIL 3 passed, 1 failed + +# Stream logs in real-time +$ wright logs a1b2c3d4 +[12:35:02] LOOP_START Dev loop iteration 2/10 +[12:35:30] EDIT Files edited +[12:35:42] TEST_PASS 4 passed, 0 failed +[12:35:45] PR_CREATED https://github.com/OpenAdaptAI/openadapt-wright/pull/42 +[12:35:46] COMPLETED Success (2 loops, $0.67) + +# List all jobs +$ wright list +ID STATUS REPO TASK COST AGE +a1b2c3d4 succeeded OpenAdaptAI/openadapt-wright Add input validation $0.67 5m +e5f6g7h8 failed OpenAdaptAI/openadapt-herald Fix RSS feed parser $2.10 2h + +# Run locally (no Supabase) +$ wright local https://github.com/OpenAdaptAI/openadapt-wright "Add --dry-run flag to deploy script" +[local] Cloning https://github.com/OpenAdaptAI/openadapt-wright... +[local] Detected: test_runner=vitest, package_manager=pnpm +[local] Installing dependencies... +[local] Loop 1/10 — running Claude session... +[local] Loop 1/10 — running tests... +[local] Tests: 53 passed, 1 failed +[local] Loop 2/10 — running Claude session... +[local] Loop 2/10 — running tests... +[local] Tests: 54 passed, 0 failed +[local] Committing and pushing... +[local] PR created: https://github.com/OpenAdaptAI/openadapt-wright/pull/43 +[local] Done! 2 loops, $0.52 +``` diff --git a/docs/screenshots/demo-desktop.webp b/docs/screenshots/demo-desktop.webp new file mode 100644 index 0000000..1834d2d Binary files /dev/null and b/docs/screenshots/demo-desktop.webp differ diff --git a/docs/screenshots/demo-mobile.webp b/docs/screenshots/demo-mobile.webp new file mode 100644 index 0000000..02238c8 Binary files /dev/null and b/docs/screenshots/demo-mobile.webp differ diff --git a/docs/screenshots/desktop-01-task-submit.png b/docs/screenshots/desktop-01-task-submit.png new file mode 100644 index 0000000..c5ae8e1 Binary files /dev/null and b/docs/screenshots/desktop-01-task-submit.png differ diff --git a/docs/screenshots/desktop-02-progress-events.png b/docs/screenshots/desktop-02-progress-events.png new file mode 100644 index 0000000..88f76d2 Binary files /dev/null and b/docs/screenshots/desktop-02-progress-events.png differ diff --git a/docs/screenshots/desktop-03-job-completed.png b/docs/screenshots/desktop-03-job-completed.png new file mode 100644 index 0000000..c05cf12 Binary files /dev/null and b/docs/screenshots/desktop-03-job-completed.png differ diff --git a/docs/screenshots/desktop-04-github-pr.png b/docs/screenshots/desktop-04-github-pr.png new file mode 100644 index 0000000..b8f83da Binary files /dev/null and b/docs/screenshots/desktop-04-github-pr.png differ diff --git a/docs/screenshots/mobile-01-task-submit.png b/docs/screenshots/mobile-01-task-submit.png new file mode 100644 index 0000000..e42ff5e Binary files /dev/null and b/docs/screenshots/mobile-01-task-submit.png differ diff --git a/docs/screenshots/mobile-02-task-command.png b/docs/screenshots/mobile-02-task-command.png new file mode 100644 index 0000000..1547e03 Binary files /dev/null and b/docs/screenshots/mobile-02-task-command.png differ diff --git a/docs/screenshots/mobile-03-progress-events.png b/docs/screenshots/mobile-03-progress-events.png new file mode 100644 index 0000000..3066c93 Binary files /dev/null and b/docs/screenshots/mobile-03-progress-events.png differ diff --git a/docs/screenshots/mobile-04-job-completed.png b/docs/screenshots/mobile-04-job-completed.png new file mode 100644 index 0000000..b731278 Binary files /dev/null and b/docs/screenshots/mobile-04-job-completed.png differ diff --git a/docs/screenshots/mobile-05-pr-results.png b/docs/screenshots/mobile-05-pr-results.png new file mode 100644 index 0000000..aab68f5 Binary files /dev/null and b/docs/screenshots/mobile-05-pr-results.png differ diff --git a/docs/screenshots/mobile-06-pr-summary.png b/docs/screenshots/mobile-06-pr-summary.png new file mode 100644 index 0000000..bd9c5b5 Binary files /dev/null and b/docs/screenshots/mobile-06-pr-summary.png differ diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 4830242..a405d3c 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -50,6 +50,11 @@ export interface Job { /** GitHub token for repo access */ github_token: string + /** For revision jobs: the existing feature branch to push to (e.g. wright/14da897c) */ + feature_branch?: string + /** For revision jobs: the ID of the original job this is revising */ + parent_job_id?: string + // Telegram integration telegram_chat_id?: number telegram_message_id?: number diff --git a/supabase/migrations/20260303000000_add_revision_columns.sql b/supabase/migrations/20260303000000_add_revision_columns.sql new file mode 100644 index 0000000..81795f9 --- /dev/null +++ b/supabase/migrations/20260303000000_add_revision_columns.sql @@ -0,0 +1,6 @@ +-- Add columns for PR revision support +-- feature_branch: the existing branch to push to (e.g. wright/14da897c) +-- parent_job_id: the original job this revision is based on + +ALTER TABLE job_queue ADD COLUMN feature_branch TEXT; +ALTER TABLE job_queue ADD COLUMN parent_job_id UUID REFERENCES job_queue(id);