Skip to content
Draft
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -157,6 +168,32 @@ Plans are saved to `.planeteer/` in the current working directory:
- `<plan-id>.json` — Machine-readable plan (used by the app)
- `<plan-id>.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

```
Expand All @@ -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
```
Expand Down
11 changes: 10 additions & 1 deletion src/screens/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,12 +24,17 @@ export default function HomeScreen({ onNewPlan, onLoadPlan, onExecutePlan, onVal
const [commandMode, setCommandMode] = useState(false);
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
const [summarized, setSummarized] = useState('');
const [cliInfo, setCliInfo] = useState<CliInfo | null>(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 = [
Expand Down Expand Up @@ -113,6 +119,9 @@ export default function HomeScreen({ onNewPlan, onLoadPlan, onExecutePlan, onVal
<Box marginBottom={1}>
<Text bold color="green">🌍 Planeteer</Text>
<Text color="gray"> — AI-powered work breakdown</Text>
{cliInfo && (
<Text color="dim"> [{cliInfo.source} CLI v{cliInfo.version}]</Text>
)}
</Box>

{showModelPicker ? (
Expand Down
62 changes: 60 additions & 2 deletions src/services/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -78,14 +79,71 @@ export function getModelLabel(): string {

let client: CopilotClient | null = null;
let clientPromise: Promise<CopilotClient> | 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<CopilotClient> {
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 }
);
Comment on lines 130 to 143
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rethrown error from c.start() drops the original error object/stack, which makes diagnosing CLI startup failures harder. Consider preserving the original error as cause (or otherwise propagating the original stack) while still adding the extra troubleshooting context.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eaa681b - now using { cause: err } to preserve the original error object and stack trace while adding troubleshooting context.

throw enhancedError;
}

client = c;
return c;
})();
Expand Down
30 changes: 30 additions & 0 deletions src/utils/cli-locator.test.ts
Original file line number Diff line number Diff line change
@@ -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)$/);
}
});
});
158 changes: 158 additions & 0 deletions src/utils/cli-locator.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}