From d6d1d0989e357de740641a315ce10a36d5ba9486 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Sun, 8 Mar 2026 11:08:31 +0000 Subject: [PATCH 1/5] feat(wizard): add shared wizard utilities (credential prompt, browse-or-url) Shared utilities for integration wizards: - ensureCredentials(): check for existing auth, prompt for API key if missing - askBrowseOrUrl(): common "browse or paste URL" prompt - askForUrl(): URL input with domain validation Co-Authored-By: Claude Opus 4.6 --- src/integrations/wizards/shared.ts | 113 +++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/integrations/wizards/shared.ts diff --git a/src/integrations/wizards/shared.ts b/src/integrations/wizards/shared.ts new file mode 100644 index 0000000..30dfb6d --- /dev/null +++ b/src/integrations/wizards/shared.ts @@ -0,0 +1,113 @@ +/** + * Shared wizard utilities for integration wizards (GitHub, Linear, Notion). + * + * Provides credential prompting, browse-or-URL selection, and URL input + * that are reused across all integration wizards. + */ + +import chalk from 'chalk'; +import inquirer from 'inquirer'; +import { getSourceCredentials, setSourceCredential } from '../../sources/config.js'; + +export interface CredentialOptions { + /** The key name in the credentials store (e.g., 'token', 'apiKey') */ + credKey: string; + /** URL where the user can create/find their credential */ + consoleUrl: string; + /** Environment variable name (e.g., 'GITHUB_TOKEN') */ + envVar: string; + /** Optional: check CLI auth before prompting (returns true if authenticated) */ + checkCliAuth?: () => Promise; +} + +/** + * Ensure credentials exist for a source. If missing, prompt the user to enter them. + * Returns the credential value. + */ +export async function ensureCredentials( + sourceName: string, + displayName: string, + opts: CredentialOptions +): Promise { + // Check CLI auth first (e.g., gh auth status) + if (opts.checkCliAuth) { + try { + const cliAuthed = await opts.checkCliAuth(); + if (cliAuthed) return '__cli_auth__'; + } catch { + // CLI not available, fall through to token check + } + } + + // Check existing credentials (env var or config file) + const existing = getSourceCredentials(sourceName); + const existingValue = existing?.[opts.credKey] || existing?.token || existing?.apiKey; + if (existingValue) return existingValue; + + // No credentials found — prompt user + console.log(); + console.log(chalk.yellow(` No ${displayName} credentials found.`)); + console.log(chalk.dim(` Get your token/key at: ${opts.consoleUrl}`)); + console.log(chalk.dim(` Or set env var: export ${opts.envVar}=`)); + console.log(); + + const { credential } = await inquirer.prompt([ + { + type: 'password', + name: 'credential', + message: `${displayName} API key/token:`, + mask: '*', + validate: (input: string) => + input.trim().length > 0 ? true : 'Please enter your API key or token', + }, + ]); + + const trimmed = credential.trim(); + setSourceCredential(sourceName, opts.credKey, trimmed); + console.log(chalk.green(` Saved to ~/.ralph-starter/sources.json`)); + console.log(); + + return trimmed; +} + +/** + * Ask the user whether they want to browse interactively or paste a URL. + */ +export async function askBrowseOrUrl(displayName: string): Promise<'browse' | 'url'> { + const { choice } = await inquirer.prompt([ + { + type: 'select', + name: 'choice', + message: `How do you want to select from ${displayName}?`, + choices: [ + { name: `Browse my ${displayName} interactively`, value: 'browse' }, + { name: 'Paste a URL', value: 'url' }, + ], + }, + ]); + + return choice; +} + +/** + * Prompt the user to paste a URL, with domain validation. + */ +export async function askForUrl(displayName: string, domainPattern: RegExp): Promise { + const { url } = await inquirer.prompt([ + { + type: 'input', + name: 'url', + message: `${displayName} URL:`, + validate: (input: string) => { + const trimmed = input.trim(); + if (!trimmed) return 'Please enter a URL'; + if (!domainPattern.test(trimmed)) { + return `Please enter a valid ${displayName} URL`; + } + return true; + }, + }, + ]); + + return url.trim(); +} From 7633fbaac7244db81802cd921445431cbfa35e2a Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Sun, 8 Mar 2026 11:08:38 +0000 Subject: [PATCH 2/5] feat(wizard): add GitHub interactive wizard command New `githubCommand()` that guides users through: 1. Authentication check (gh CLI or token prompt) 2. Browse repos/issues or paste a URL 3. Optional label filtering 4. Multi-select issues (checkbox) 5. Delegates to runCommand() for each selected issue Co-Authored-By: Claude Opus 4.6 --- src/commands/github.ts | 299 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 src/commands/github.ts diff --git a/src/commands/github.ts b/src/commands/github.ts new file mode 100644 index 0000000..26a3ba0 --- /dev/null +++ b/src/commands/github.ts @@ -0,0 +1,299 @@ +/** + * ralph-starter github — Interactive GitHub issues wizard + * + * Guides the user through selecting GitHub issues to work on: + * 1. Authenticate (gh CLI or token) + * 2. Browse repos + issues or paste a URL + * 3. Select issues (multi-select) + * 4. Delegate to run command + */ + +import chalk from 'chalk'; +import inquirer from 'inquirer'; +import { askBrowseOrUrl, askForUrl, ensureCredentials } from '../integrations/wizards/shared.js'; +import { type RunCommandOptions, runCommand } from './run.js'; + +export interface GitHubWizardOptions { + commit?: boolean; + push?: boolean; + pr?: boolean; + validate?: boolean; + maxIterations?: number; + agent?: string; +} + +interface GitHubRepo { + name: string; + owner: { login: string }; + description: string; +} + +interface GitHubIssue { + number: number; + title: string; + labels: Array<{ name: string }>; +} + +interface GitHubLabel { + name: string; +} + +/** Check if gh CLI is available and authenticated */ +async function isGhCliAvailable(): Promise { + try { + const { execa } = await import('execa'); + await execa('gh', ['auth', 'status']); + return true; + } catch { + return false; + } +} + +/** Fetch user's repos via gh CLI */ +async function fetchReposViaCli(limit = 30): Promise { + const { execa } = await import('execa'); + const result = await execa('gh', [ + 'repo', + 'list', + '--json', + 'name,owner,description', + '--limit', + String(limit), + '--sort', + 'updated', + ]); + return JSON.parse(result.stdout); +} + +/** Fetch open issues for a repo via gh CLI */ +async function fetchIssuesViaCli( + owner: string, + repo: string, + label?: string, + limit = 30 +): Promise { + const { execa } = await import('execa'); + const args = [ + 'issue', + 'list', + '-R', + `${owner}/${repo}`, + '--json', + 'number,title,labels', + '--limit', + String(limit), + '--state', + 'open', + ]; + if (label) { + args.push('--label', label); + } + const result = await execa('gh', args); + return JSON.parse(result.stdout); +} + +/** Fetch labels for a repo via gh CLI */ +async function fetchLabelsViaCli(owner: string, repo: string): Promise { + const { execa } = await import('execa'); + const result = await execa('gh', [ + 'label', + 'list', + '-R', + `${owner}/${repo}`, + '--json', + 'name', + '--limit', + '50', + ]); + return JSON.parse(result.stdout); +} + +/** Parse a GitHub URL into owner/repo and optional issue number */ +function parseGitHubUrl(url: string): { owner: string; repo: string; issue?: number } | null { + // Match: github.com/owner/repo/issues/123 + const issueMatch = url.match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/); + if (issueMatch) { + return { + owner: issueMatch[1], + repo: issueMatch[2].replace(/\.git$/, ''), + issue: parseInt(issueMatch[3], 10), + }; + } + + // Match: github.com/owner/repo + const repoMatch = url.match(/github\.com\/([^/]+)\/([^/]+)/); + if (repoMatch) { + return { + owner: repoMatch[1], + repo: repoMatch[2].replace(/\.git$/, '').replace(/\/$/, ''), + }; + } + + return null; +} + +export async function githubCommand(options: GitHubWizardOptions): Promise { + console.log(); + console.log(chalk.cyan.bold(' GitHub Issues')); + console.log(chalk.dim(' Build from GitHub issues interactively')); + console.log(); + + // Step 1: Ensure credentials + await ensureCredentials('github', 'GitHub', { + credKey: 'token', + consoleUrl: 'https://github.com/settings/tokens', + envVar: 'GITHUB_TOKEN', + checkCliAuth: isGhCliAvailable, + }); + + // Step 2: Browse or URL? + const mode = await askBrowseOrUrl('GitHub'); + + if (mode === 'url') { + const url = await askForUrl('GitHub', /github\.com/); + const parsed = parseGitHubUrl(url); + if (!parsed) { + console.log( + chalk.red(' Could not parse GitHub URL. Expected format: github.com/owner/repo') + ); + return; + } + + const runOpts: RunCommandOptions = { + from: 'github', + project: `${parsed.owner}/${parsed.repo}`, + issue: parsed.issue, + auto: true, + commit: options.commit ?? false, + push: options.push, + pr: options.pr, + validate: options.validate ?? true, + maxIterations: options.maxIterations, + agent: options.agent, + }; + + await runCommand(undefined, runOpts); + return; + } + + // Browse mode + // Step 3: Fetch and select repository + console.log(chalk.dim(' Fetching your repositories...')); + let repos: GitHubRepo[]; + try { + repos = await fetchReposViaCli(); + } catch (err) { + console.log(chalk.red(' Failed to fetch repositories. Check your authentication.')); + console.log(chalk.dim(` Error: ${err instanceof Error ? err.message : String(err)}`)); + return; + } + + if (repos.length === 0) { + console.log(chalk.yellow(' No repositories found.')); + return; + } + + const { selectedRepo } = await inquirer.prompt([ + { + type: 'select', + name: 'selectedRepo', + message: 'Select a repository:', + choices: repos.map((r) => ({ + name: `${r.owner.login}/${r.name}${r.description ? chalk.dim(` — ${r.description.slice(0, 60)}`) : ''}`, + value: `${r.owner.login}/${r.name}`, + })), + }, + ]); + + const [owner, repo] = selectedRepo.split('/'); + + // Step 4: Optional label filter + let selectedLabel: string | undefined; + try { + const labels = await fetchLabelsViaCli(owner, repo); + if (labels.length > 0) { + const { labelChoice } = await inquirer.prompt([ + { + type: 'select', + name: 'labelChoice', + message: 'Filter by label?', + choices: [ + { name: 'All issues (no filter)', value: '__none__' }, + ...labels.map((l) => ({ name: l.name, value: l.name })), + ], + }, + ]); + if (labelChoice !== '__none__') { + selectedLabel = labelChoice; + } + } + } catch { + // Labels fetch failed, skip filter + } + + // Step 5: Fetch and select issues + console.log(chalk.dim(` Fetching open issues for ${owner}/${repo}...`)); + let issues: GitHubIssue[]; + try { + issues = await fetchIssuesViaCli(owner, repo, selectedLabel); + } catch (err) { + console.log(chalk.red(' Failed to fetch issues.')); + console.log(chalk.dim(` Error: ${err instanceof Error ? err.message : String(err)}`)); + return; + } + + if (issues.length === 0) { + console.log( + chalk.yellow( + ` No open issues found${selectedLabel ? ` with label "${selectedLabel}"` : ''}.` + ) + ); + return; + } + + const { selectedIssues } = await inquirer.prompt([ + { + type: 'checkbox', + name: 'selectedIssues', + message: 'Select issues to work on:', + choices: issues.map((issue) => { + const labelTags = + issue.labels.length > 0 + ? ` ${chalk.dim(`[${issue.labels.map((l) => l.name).join(', ')}]`)}` + : ''; + return { + name: `#${issue.number} — ${issue.title}${labelTags}`, + value: issue.number, + }; + }), + validate: (input: number[]) => (input.length > 0 ? true : 'Please select at least one issue'), + }, + ]); + + // Step 6: Run for each selected issue + console.log(); + console.log( + chalk.green( + ` Starting build for ${selectedIssues.length} issue${selectedIssues.length > 1 ? 's' : ''}...` + ) + ); + console.log(); + + for (const issueNumber of selectedIssues) { + const runOpts: RunCommandOptions = { + from: 'github', + project: `${owner}/${repo}`, + issue: issueNumber, + label: selectedLabel, + auto: true, + commit: options.commit ?? false, + push: options.push, + pr: options.pr, + validate: options.validate ?? true, + maxIterations: options.maxIterations, + agent: options.agent, + }; + + await runCommand(undefined, runOpts); + } +} From 439e0170a8bb2540f25071129d4c42c57c377f99 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Sun, 8 Mar 2026 11:10:03 +0000 Subject: [PATCH 3/5] feat(cli): register ralph-starter github command + run --from fallback - Register `ralph-starter github` as top-level command in cli.ts (same pattern as `ralph-starter figma`) - Add wizard fallback in run.ts: `--from github` without --project/--issue redirects to the interactive github wizard instead of erroring Co-Authored-By: Claude Opus 4.6 --- src/cli.ts | 23 +++++++++++++++++++++++ src/commands/run.ts | 16 ++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/cli.ts b/src/cli.ts index 102dfce..d584c46 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ import { checkCommand } from './commands/check.js'; import { configCommand } from './commands/config.js'; import { figmaCommand } from './commands/figma.js'; import { fixCommand } from './commands/fix.js'; +import { githubCommand } from './commands/github.js'; import { initCommand } from './commands/init.js'; import { integrationsCommand } from './commands/integrations.js'; import { pauseCommand } from './commands/pause.js'; @@ -159,6 +160,28 @@ program }); }); +// ralph-starter github - Build from GitHub issues wizard +program + .command('github') + .description('Build from GitHub issues with an interactive wizard') + .option('--commit', 'Auto-commit after tasks') + .option('--push', 'Push to remote') + .option('--pr', 'Create PR when done') + .option('--validate', 'Run validation', true) + .option('--no-validate', 'Skip validation') + .option('--max-iterations ', 'Max loop iterations') + .option('--agent ', 'Agent to use') + .action(async (options) => { + await githubCommand({ + commit: options.commit, + push: options.push, + pr: options.pr, + validate: options.validate, + maxIterations: options.maxIterations ? parseInt(options.maxIterations, 10) : undefined, + agent: options.agent, + }); + }); + // ralph-starter init - Initialize Ralph in a project program .command('init') diff --git a/src/commands/run.ts b/src/commands/run.ts index cc21e74..840606e 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -34,6 +34,7 @@ import { } from '../utils/sanitize.js'; import { ensureSharp } from '../utils/sharp.js'; import { showWelcome } from '../wizard/ui.js'; +import { githubCommand } from './github.js'; /** Default fallback repo for GitHub issues when no project is specified */ const DEFAULT_GITHUB_ISSUES_REPO = 'multivmlabs/ralph-ideas'; @@ -340,6 +341,21 @@ export async function runCommand( } } + // Handle --from with wizard fallback for integrations without enough context + if (options.from && !options.project && !options.issue) { + const source = options.from.toLowerCase(); + if (source === 'github') { + return githubCommand({ + commit: options.commit, + push: options.push, + pr: options.pr, + validate: options.validate, + maxIterations: options.maxIterations, + agent: options.agent, + }); + } + } + // Handle --from source let sourceSpec: string | null = null; let sourceTitle: string | undefined; From b3e759b38eeaa43e8798da85766de883c2de2b29 Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Sun, 8 Mar 2026 11:10:37 +0000 Subject: [PATCH 4/5] docs: add GitHub wizard documentation and examples - Add "Interactive Wizard" section to docs/docs/sources/github.md - Add github/linear/notion wizard commands to README commands table Co-Authored-By: Claude Opus 4.6 --- README.md | 3 +++ docs/docs/sources/github.md | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/README.md b/README.md index 45b8bb0..9292ac7 100644 --- a/README.md +++ b/README.md @@ -456,6 +456,9 @@ This creates: | `ralph-starter` | Launch interactive wizard | | `ralph-starter run [task]` | Run an autonomous coding loop | | `ralph-starter fix [task]` | Fix build errors, lint issues, or design problems | +| `ralph-starter github` | Interactive GitHub issues wizard | +| `ralph-starter linear` | Interactive Linear issues wizard | +| `ralph-starter notion` | Interactive Notion pages wizard | | `ralph-starter auto` | Batch-process issues from GitHub/Linear | | `ralph-starter task ` | Manage tasks across GitHub and Linear (list, create, update, close, comment) | | `ralph-starter integrations ` | Manage integrations (list, help, test, fetch) | diff --git a/docs/docs/sources/github.md b/docs/docs/sources/github.md index dd59adb..7b9e2a2 100644 --- a/docs/docs/sources/github.md +++ b/docs/docs/sources/github.md @@ -35,6 +35,28 @@ Required scopes: - `repo` (for private repositories) - `public_repo` (for public repositories only) +## Interactive Wizard + +The easiest way to get started: + +```bash +ralph-starter github +``` + +This will: +1. Check your authentication (prompt for token if needed) +2. Let you browse repositories and select issues +3. Multi-select which issues to work on +4. Start the build loop automatically + +You can also pass options: + +```bash +ralph-starter github --commit --pr --validate +``` + +If you prefer the CLI flags approach, use `ralph-starter run --from github` (see below). + ## Usage ```bash From f9a1394d79e91c03fbe0bd65a4c05c49f327013b Mon Sep 17 00:00:00 2001 From: ruben-cytonic Date: Sun, 8 Mar 2026 11:43:33 +0000 Subject: [PATCH 5/5] fix: anchor regex patterns and use type over interface - Anchor GitHub URL regex patterns with ^https?:// (CodeQL fix) - Change interface to type for plain data structures (Greptile) Co-Authored-By: Claude Opus 4.6 --- src/commands/github.ts | 26 +++++++++++++------------- src/integrations/wizards/shared.ts | 4 ++-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/commands/github.ts b/src/commands/github.ts index 26a3ba0..98ca4dd 100644 --- a/src/commands/github.ts +++ b/src/commands/github.ts @@ -13,30 +13,30 @@ import inquirer from 'inquirer'; import { askBrowseOrUrl, askForUrl, ensureCredentials } from '../integrations/wizards/shared.js'; import { type RunCommandOptions, runCommand } from './run.js'; -export interface GitHubWizardOptions { +export type GitHubWizardOptions = { commit?: boolean; push?: boolean; pr?: boolean; validate?: boolean; maxIterations?: number; agent?: string; -} +}; -interface GitHubRepo { +type GitHubRepo = { name: string; owner: { login: string }; description: string; -} +}; -interface GitHubIssue { +type GitHubIssue = { number: number; title: string; labels: Array<{ name: string }>; -} +}; -interface GitHubLabel { +type GitHubLabel = { name: string; -} +}; /** Check if gh CLI is available and authenticated */ async function isGhCliAvailable(): Promise { @@ -110,8 +110,8 @@ async function fetchLabelsViaCli(owner: string, repo: string): Promise const mode = await askBrowseOrUrl('GitHub'); if (mode === 'url') { - const url = await askForUrl('GitHub', /github\.com/); + const url = await askForUrl('GitHub', /^https?:\/\/github\.com\//); const parsed = parseGitHubUrl(url); if (!parsed) { console.log( diff --git a/src/integrations/wizards/shared.ts b/src/integrations/wizards/shared.ts index 30dfb6d..eb63fb5 100644 --- a/src/integrations/wizards/shared.ts +++ b/src/integrations/wizards/shared.ts @@ -9,7 +9,7 @@ import chalk from 'chalk'; import inquirer from 'inquirer'; import { getSourceCredentials, setSourceCredential } from '../../sources/config.js'; -export interface CredentialOptions { +export type CredentialOptions = { /** The key name in the credentials store (e.g., 'token', 'apiKey') */ credKey: string; /** URL where the user can create/find their credential */ @@ -18,7 +18,7 @@ export interface CredentialOptions { envVar: string; /** Optional: check CLI auth before prompting (returns true if authenticated) */ checkCliAuth?: () => Promise; -} +}; /** * Ensure credentials exist for a source. If missing, prompt the user to enter them.