diff --git a/README.md b/README.md index 9987d3b..09fe29d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ AI-powered work breakdown and parallel execution TUI. Describe what you want to ## Prerequisites - **Node.js 22+** -- **GitHub Copilot CLI** installed and authenticated (`npm install -g @github/copilot && copilot auth`) ## Quick Start @@ -23,6 +22,18 @@ npm run build npm start # Launch the TUI (home screen) ``` +**Note:** Planeteer bundles the GitHub Copilot CLI automatically. You'll need to authenticate with GitHub Copilot on first use (via GitHub authentication in your browser). + +## Features + +- **Zero-config CLI**: The GitHub Copilot CLI is bundled with Planeteer — no separate installation needed +- **Automatic fallback**: Uses bundled CLI by default, falls back to system-installed CLI if available +- **Version detection**: Displays CLI version and source (bundled vs. system) on the home screen +- **Multi-turn clarification**: Copilot asks questions to understand your project requirements +- **Smart work breakdown**: Generates tasks with acceptance criteria and dependency graphs +- **Parallel execution**: Runs independent tasks simultaneously via Copilot agents +- **Progress tracking**: Real-time updates on task execution with event logs + ## Usage ```bash @@ -157,6 +168,32 @@ Plans are saved to `.planeteer/` in the current working directory: - `.json` — Machine-readable plan (used by the app) - `.md` — Human-readable Markdown export +## Troubleshooting + +### CLI Not Found + +If you see "GitHub Copilot CLI not found" errors: + +1. **Reinstall dependencies**: `npm install` (ensures bundled CLI is installed) +2. **Check platform support**: The bundled CLI supports Linux (x64, arm64), macOS (x64, arm64), and Windows (x64, arm64) +3. **Manual installation**: As a fallback, install globally with `npm install -g @github/copilot` + +### Authentication Issues + +The Copilot CLI requires GitHub authentication: + +1. On first use, you'll be prompted to authenticate via your browser +2. You need an active GitHub Copilot subscription +3. If authentication fails, try running `copilot auth` in your terminal + +### CLI Version Mismatch + +If you experience issues with the bundled CLI: + +- Check the CLI version on the home screen (displays as `[bundled CLI vX.X.X]`) +- The bundled version is tied to `@github/copilot-sdk` version in package.json +- To use a newer CLI, install it globally: `npm install -g @github/copilot` + ## Project Structure ``` @@ -180,6 +217,7 @@ src/ ├── models/ │ └── plan.ts # Types: Plan, Task, ChatMessage └── utils/ + ├── cli-locator.ts # Locate bundled/system Copilot CLI ├── dependency-graph.ts # Topological sort & cycle detection └── markdown.ts # Plan → Markdown renderer ``` diff --git a/src/screens/home.tsx b/src/screens/home.tsx index 68e2d50..84001ae 100644 --- a/src/screens/home.tsx +++ b/src/screens/home.tsx @@ -3,7 +3,8 @@ import { Box, Text, useInput } from 'ink'; import SelectInput from 'ink-select-input'; import type { Plan } from '../models/plan.js'; import { listPlans, loadPlan, summarizePlan } from '../services/persistence.js'; -import { fetchModels, getModel, setModel, getModelLabel, type ModelEntry } from '../services/copilot.js'; +import { fetchModels, getModel, setModel, getModelLabel, getCliInfo, type ModelEntry } from '../services/copilot.js'; +import type { CliInfo } from '../utils/cli-locator.js'; import StatusBar from '../components/status-bar.js'; interface HomeScreenProps { @@ -23,12 +24,17 @@ export default function HomeScreen({ onNewPlan, onLoadPlan, onExecutePlan, onVal const [commandMode, setCommandMode] = useState(false); const [selectedPlanId, setSelectedPlanId] = useState(null); const [summarized, setSummarized] = useState(''); + const [cliInfo, setCliInfo] = useState(null); React.useEffect(() => { listPlans().then((plans) => { setSavedPlans(plans); setLoaded(true); }); + // Load CLI info asynchronously (off the initial render path) + Promise.resolve().then(() => { + setCliInfo(getCliInfo()); + }); }, []); const items = [ @@ -113,6 +119,9 @@ export default function HomeScreen({ onNewPlan, onLoadPlan, onExecutePlan, onVal 🌍 Planeteer — AI-powered work breakdown + {cliInfo && ( + [{cliInfo.source} CLI v{cliInfo.version}] + )} {showModelPicker ? ( diff --git a/src/services/copilot.ts b/src/services/copilot.ts index b691e58..791c6df 100644 --- a/src/services/copilot.ts +++ b/src/services/copilot.ts @@ -3,6 +3,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { existsSync } from 'node:fs'; import type { ChatMessage } from '../models/plan.js'; +import { locateCopilotCli, type CliInfo } from '../utils/cli-locator.js'; const SETTINGS_PATH = join(process.cwd(), '.planeteer', 'settings.json'); @@ -78,14 +79,71 @@ export function getModelLabel(): string { let client: CopilotClient | null = null; let clientPromise: Promise | null = null; +let cliLocation: CliInfo | null = null; + +/** Initialize CLI location info early (doesn't start the client). */ +export function initCliInfo(): void { + if (!cliLocation) { + const location = locateCopilotCli(); + if (location) { + cliLocation = location; + } + } +} + +/** Get information about the CLI being used. */ +export function getCliInfo(): CliInfo | null { + // Initialize on first access if not already done + if (!cliLocation) { + initCliInfo(); + } + return cliLocation; +} export async function getClient(): Promise { if (client) return client; if (clientPromise) return clientPromise; clientPromise = (async () => { - const c = new CopilotClient(); - await c.start(); + // Initialize CLI location if not already done + initCliInfo(); + + // Use the cached location + if (!cliLocation) { + throw new Error( + 'GitHub Copilot CLI not found.\n\n' + + 'The bundled CLI should be automatically available, but it appears to be missing.\n' + + 'Please try:\n' + + ' 1. Reinstalling dependencies: npm install\n' + + ' 2. Installing the CLI globally: npm install -g @github/copilot\n\n' + + 'If the problem persists, please report this issue.' + ); + } + + // Create client with the located CLI path + const c = new CopilotClient({ + cliPath: cliLocation.path, + }); + + try { + await c.start(); + } catch (err) { + const message = (err as Error).message || 'Unknown error'; + const enhancedError = new Error( + `Failed to start GitHub Copilot CLI.\n\n` + + `Error: ${message}\n\n` + + `The CLI was found at: ${cliLocation.path}\n` + + `Version: ${cliLocation.version}\n` + + `Source: ${cliLocation.source}\n\n` + + `Please ensure you have:\n` + + ` 1. Authenticated with GitHub Copilot (the CLI will prompt you)\n` + + ` 2. Active GitHub Copilot subscription\n` + + ` 3. Proper permissions to execute the CLI binary`, + { cause: err } + ); + throw enhancedError; + } + client = c; return c; })(); diff --git a/src/utils/cli-locator.test.ts b/src/utils/cli-locator.test.ts new file mode 100644 index 0000000..0f3ae4a --- /dev/null +++ b/src/utils/cli-locator.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { locateCopilotCli } from './cli-locator.js'; + +describe('cli-locator', () => { + it('should return null or valid CLI info', () => { + const location = locateCopilotCli(); + + // Location may be null if CLI is not available (e.g., CI with --omit=optional) + if (location) { + expect(location.path).toBeTruthy(); + expect(['bundled', 'system']).toContain(location.source); + expect(location.version).toBeTruthy(); + } else { + // If no CLI is found, location should be null + expect(location).toBeNull(); + } + }); + + it('should return valid structure when CLI is found', () => { + const location = locateCopilotCli(); + + // Only validate structure if a CLI was found + if (location) { + expect(location).toHaveProperty('path'); + expect(location).toHaveProperty('version'); + expect(location).toHaveProperty('source'); + expect(location.source).toMatch(/^(bundled|system)$/); + } + }); +}); diff --git a/src/utils/cli-locator.ts b/src/utils/cli-locator.ts new file mode 100644 index 0000000..f3c2ecb --- /dev/null +++ b/src/utils/cli-locator.ts @@ -0,0 +1,158 @@ +import { existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; + +export interface CliInfo { + path: string; + version: string; + source: 'bundled' | 'system'; +} + +/** + * Locate the bundled Copilot CLI binary. + * Returns the path if found, otherwise null. + */ +function findBundledCli(): string | null { + try { + // Try to resolve the platform-specific package + const platform = process.platform; + const arch = process.arch; + const packageName = `@github/copilot-${platform}-${arch}`; + + // Attempt to resolve via import.meta.resolve + try { + const resolved = import.meta.resolve(packageName); + const packagePath = fileURLToPath(resolved); + const binaryDir = dirname(packagePath); + const binaryName = platform === 'win32' ? 'copilot.exe' : 'copilot'; + const binaryPath = join(binaryDir, binaryName); + + if (existsSync(binaryPath)) { + return binaryPath; + } + } catch { + // If import.meta.resolve fails, try manual path construction + } + + // Fallback: Construct path from this file's location + // Assuming this file is in src/utils/ and node_modules is at repo root + const currentFile = fileURLToPath(import.meta.url); + const repoRoot = join(dirname(currentFile), '..', '..'); + + // Try node_modules location + const nodeModulesPath = join( + repoRoot, + 'node_modules', + '@github', + `copilot-${platform}-${arch}`, + platform === 'win32' ? 'copilot.exe' : 'copilot' + ); + + if (existsSync(nodeModulesPath)) { + return nodeModulesPath; + } + + // Try prebuilds location (legacy structure) + const prebuildsPath = join( + repoRoot, + 'node_modules', + '@github', + 'copilot', + 'prebuilds', + `${platform}-${arch}`, + platform === 'win32' ? 'copilot.exe' : 'copilot' + ); + + if (existsSync(prebuildsPath)) { + return prebuildsPath; + } + } catch { + // Ignore errors + } + + return null; +} + +/** + * Find the system-installed Copilot CLI. + * Returns the path if found, otherwise null. + */ +function findSystemCli(): string | null { + try { + const executable = process.platform === 'win32' ? 'where' : 'which'; + const result = execFileSync(executable, ['copilot'], { + encoding: 'utf-8', + // Prevent PATH lookups from hanging indefinitely + timeout: 2000, + }); + const path = result.trim().split('\n')[0]; + + if (path && existsSync(path)) { + return path; + } + } catch (error) { + // On timeout or lookup errors, treat as "CLI not in PATH" + return null; + } + + return null; +} + +/** + * Get the version of a CLI binary. + */ +function getCliVersion(cliPath: string): string { + try { + const result = execFileSync(cliPath, ['--version'], { + encoding: 'utf-8', + timeout: 5000, + }); + + // Parse version from output (e.g., "GitHub Copilot CLI 0.0.403") + const match = result.match(/(\d+\.\d+\.\d+)/); + return match ? match[1] : 'unknown'; + } catch { + return 'unknown'; + } +} + +/** + * Locate the Copilot CLI binary, checking bundled first, then system. + * Returns null if no CLI is found. + */ +export function locateCopilotCli(): CliInfo | null { + // Try bundled CLI first + const bundledPath = findBundledCli(); + if (bundledPath) { + const version = getCliVersion(bundledPath); + return { path: bundledPath, version, source: 'bundled' }; + } + + // Fallback to system CLI + const systemPath = findSystemCli(); + if (systemPath) { + const version = getCliVersion(systemPath); + return { path: systemPath, version, source: 'system' }; + } + + return null; +} + +/** + * Check if the CLI binary is executable. + * Returns true if the binary can be executed, false otherwise. + * Note: This does NOT verify authentication status. + */ +export function checkCliExecutable(cliPath: string): boolean { + try { + // Run a simple command to verify the binary is executable + execFileSync(cliPath, ['--help'], { + encoding: 'utf-8', + timeout: 5000, + }); + return true; + } catch { + return false; + } +}