Skip to content

feat(cli): Add checking for git and gh CLI tools in GitHub mode #230

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Mar 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 14 additions & 19 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,47 +52,42 @@ MyCoder includes a GitHub mode that enables the agent to work with GitHub issues
- Create PRs when work is complete
- Create additional GitHub issues for follow-up tasks or ideas

To enable GitHub mode:
GitHub mode is **enabled by default** but requires the Git and GitHub CLI tools to be installed and configured:

1. Via CLI option (overrides config file):

```bash
mycoder --githubMode true
```

2. Via configuration file:
- Git CLI (`git`) must be installed
- GitHub CLI (`gh`) must be installed and authenticated

```js
// mycoder.config.js
export default {
githubMode: true,
// other configuration options...
};
```
MyCoder will automatically check for these requirements when GitHub mode is enabled and will:
- Warn you if any requirements are missing
- Automatically disable GitHub mode if the required tools are not available or not authenticated

To disable GitHub mode:
To manually enable/disable GitHub mode:

1. Via CLI option:
1. Via CLI option (overrides config file):

```bash
mycoder --githubMode false
mycoder --githubMode true # Enable GitHub mode
mycoder --githubMode false # Disable GitHub mode
```

2. Via configuration file:

```js
// mycoder.config.js
export default {
githubMode: false,
githubMode: true, // Enable GitHub mode (default)
// other configuration options...
};
```

Requirements for GitHub mode:

- Git CLI (`git`) needs to be installed
- GitHub CLI (`gh`) needs to be installed and authenticated
- User needs to have appropriate GitHub permissions for the target repository

If GitHub mode is enabled but the requirements are not met, MyCoder will provide instructions on how to install and configure the missing tools.

## Configuration

MyCoder is configured using a `mycoder.config.js` file in your project root, similar to ESLint and other modern JavaScript tools. This file exports a configuration object with your preferred settings.
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/src/commands/$default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { TokenTracker } from 'mycoder-agent/dist/core/tokens.js';
import { SharedOptions } from '../options.js';
import { captureException } from '../sentry/index.js';
import { getConfigFromArgv, loadConfig } from '../settings/config.js';
import { checkGitCli } from '../utils/gitCliCheck.js';
import { nameToLogIndex } from '../utils/nameToLogIndex.js';
import { checkForUpdates, getPackageInfo } from '../utils/versionCheck.js';

Expand Down Expand Up @@ -58,6 +59,47 @@ export const command: CommandModule<SharedOptions, DefaultArgs> = {
if (config.upgradeCheck !== false) {
await checkForUpdates(logger);
}

// Check for git and gh CLI tools if GitHub mode is enabled
if (config.githubMode) {
logger.debug(
'GitHub mode is enabled, checking for git and gh CLI tools...',
);
const gitCliCheck = await checkGitCli(logger);

if (gitCliCheck.errors.length > 0) {
logger.warn(
'GitHub mode is enabled but there are issues with git/gh CLI tools:',
);
gitCliCheck.errors.forEach((error) => logger.warn(`- ${error}`));

if (!gitCliCheck.gitAvailable || !gitCliCheck.ghAvailable) {
logger.warn(
'GitHub mode requires git and gh CLI tools to be installed.',
);
logger.warn(
'Please install the missing tools or disable GitHub mode with --githubMode false',
);
// Disable GitHub mode if git or gh CLI is not available
logger.info('Disabling GitHub mode due to missing CLI tools.');
config.githubMode = false;
} else if (!gitCliCheck.ghAuthenticated) {
logger.warn(
'GitHub CLI is not authenticated. Please run "gh auth login" to authenticate.',
);
// Disable GitHub mode if gh CLI is not authenticated
logger.info(
'Disabling GitHub mode due to unauthenticated GitHub CLI.',
);
config.githubMode = false;
}
} else {
logger.info(
'GitHub mode is enabled and all required CLI tools are available.',
);
}
}

const tokenTracker = new TokenTracker(
'Root',
undefined,
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ export const sharedOptions = {
} as const,
githubMode: {
type: 'boolean',
description: 'Enable GitHub mode for working with issues and PRs',
description:
'Enable GitHub mode for working with issues and PRs (requires git and gh CLI tools)',
default: true,
} as const,
upgradeCheck: {
type: 'boolean',
Expand Down
138 changes: 138 additions & 0 deletions packages/cli/src/utils/gitCliCheck.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { exec } from 'child_process';

import { describe, it, expect, vi, beforeEach } from 'vitest';

import { checkGitCli } from './gitCliCheck';

// Mock the child_process module
vi.mock('child_process', () => ({
exec: vi.fn(),
}));

// Mock the util module
vi.mock('util', () => ({
promisify: vi.fn((fn) => {
return (cmd: string) => {
return new Promise((resolve, reject) => {
fn(cmd, (error: Error | null, result: { stdout: string }) => {
if (error) {
reject(error);
} else {
resolve(result);
}
});
});
};
}),
}));

describe('gitCliCheck', () => {
const mockExec = exec as unknown as vi.Mock;

beforeEach(() => {
mockExec.mockReset();
});

it('should return all true when git and gh are available and authenticated', async () => {
// Mock successful responses
mockExec.mockImplementation(
(
cmd: string,
callback: (error: Error | null, result: { stdout: string }) => void,
) => {
if (cmd === 'git --version') {
callback(null, { stdout: 'git version 2.30.1' });
} else if (cmd === 'gh --version') {
callback(null, { stdout: 'gh version 2.0.0' });
} else if (cmd === 'gh auth status') {
callback(null, { stdout: 'Logged in to github.com as username' });
}
},
);

const result = await checkGitCli();

expect(result.gitAvailable).toBe(true);
expect(result.ghAvailable).toBe(true);
expect(result.ghAuthenticated).toBe(true);
expect(result.errors).toHaveLength(0);
});

it('should detect when git is not available', async () => {
mockExec.mockImplementation(
(
cmd: string,
callback: (error: Error | null, result: { stdout: string }) => void,
) => {
if (cmd === 'git --version') {
callback(new Error('Command not found'), { stdout: '' });
} else if (cmd === 'gh --version') {
callback(null, { stdout: 'gh version 2.0.0' });
} else if (cmd === 'gh auth status') {
callback(null, { stdout: 'Logged in to github.com as username' });
}
},
);

const result = await checkGitCli();

expect(result.gitAvailable).toBe(false);
expect(result.ghAvailable).toBe(true);
expect(result.ghAuthenticated).toBe(true);
expect(result.errors).toContain(
'Git CLI is not available. Please install git.',
);
});

it('should detect when gh is not available', async () => {
mockExec.mockImplementation(
(
cmd: string,
callback: (error: Error | null, result: { stdout: string }) => void,
) => {
if (cmd === 'git --version') {
callback(null, { stdout: 'git version 2.30.1' });
} else if (cmd === 'gh --version') {
callback(new Error('Command not found'), { stdout: '' });
}
},
);

const result = await checkGitCli();

expect(result.gitAvailable).toBe(true);
expect(result.ghAvailable).toBe(false);
expect(result.ghAuthenticated).toBe(false);
expect(result.errors).toContain(
'GitHub CLI is not available. Please install gh CLI.',
);
});

it('should detect when gh is not authenticated', async () => {
mockExec.mockImplementation(
(
cmd: string,
callback: (error: Error | null, result: { stdout: string }) => void,
) => {
if (cmd === 'git --version') {
callback(null, { stdout: 'git version 2.30.1' });
} else if (cmd === 'gh --version') {
callback(null, { stdout: 'gh version 2.0.0' });
} else if (cmd === 'gh auth status') {
callback(new Error('You are not logged into any GitHub hosts'), {
stdout: '',
});
}
},
);

const result = await checkGitCli();

expect(result.gitAvailable).toBe(true);
expect(result.ghAvailable).toBe(true);
expect(result.ghAuthenticated).toBe(false);
expect(result.errors).toContain(
'GitHub CLI is not authenticated. Please run "gh auth login".',
);
});
});
92 changes: 92 additions & 0 deletions packages/cli/src/utils/gitCliCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { exec } from 'child_process';
import { promisify } from 'util';

import { Logger } from 'mycoder-agent';

const execAsync = promisify(exec);

/**
* Result of CLI tool checks
*/
export interface GitCliCheckResult {
gitAvailable: boolean;
ghAvailable: boolean;
ghAuthenticated: boolean;
errors: string[];
}

/**
* Checks if git command is available
*/
async function checkGitAvailable(): Promise<boolean> {
try {
await execAsync('git --version');
return true;
} catch {
return false;
}
}

/**
* Checks if gh command is available
*/
async function checkGhAvailable(): Promise<boolean> {
try {
await execAsync('gh --version');
return true;
} catch {
return false;
}
}

/**
* Checks if gh is authenticated
*/
async function checkGhAuthenticated(): Promise<boolean> {
try {
const { stdout } = await execAsync('gh auth status');
return stdout.includes('Logged in to');
} catch {
return false;
}
}

/**
* Checks if git and gh CLI tools are available and if gh is authenticated
* @param logger Optional logger for debug output
* @returns Object with check results
*/
export async function checkGitCli(logger?: Logger): Promise<GitCliCheckResult> {
const result: GitCliCheckResult = {
gitAvailable: false,
ghAvailable: false,
ghAuthenticated: false,
errors: [],
};

logger?.debug('Checking for git CLI availability...');
result.gitAvailable = await checkGitAvailable();

logger?.debug('Checking for gh CLI availability...');
result.ghAvailable = await checkGhAvailable();

if (result.ghAvailable) {
logger?.debug('Checking for gh CLI authentication...');
result.ghAuthenticated = await checkGhAuthenticated();
}

// Collect any errors
if (!result.gitAvailable) {
result.errors.push('Git CLI is not available. Please install git.');
}

if (!result.ghAvailable) {
result.errors.push('GitHub CLI is not available. Please install gh CLI.');
} else if (!result.ghAuthenticated) {
result.errors.push(
'GitHub CLI is not authenticated. Please run "gh auth login".',
);
}

return result;
}
3 changes: 2 additions & 1 deletion packages/cli/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,6 @@
"allowJs": false,
"checkJs": false
},
"include": ["src/**/*"]
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"]
}
Loading