copilot-cli.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. /**
  2. * GitHub Copilot CLI target.
  3. *
  4. * - MCP server entry to `~/.copilot/mcp-config.json` under the
  5. * `mcpServers` key (same wrapper as Claude/Cursor). Entry shape per
  6. * the GitHub docs: `{ "type": "stdio", "command", "args", "tools" }`
  7. * — `type` accepts `"local"` or `"stdio"`; we write `"stdio"` (the
  8. * standard MCP name, recommended by the docs for cross-client
  9. * compatibility). `"tools": ["*"]` mirrors the docs' example and is
  10. * the documented default.
  11. * - The config dir is `~/.copilot` unless the user moved it via
  12. * `COPILOT_HOME` (documented override) — we honor it so install and
  13. * detect follow the CLI's own resolution.
  14. *
  15. * Copilot CLI as of 2026-07 has no project-local MCP config — per-repo
  16. * config (`.github/mcp.json`) is an open feature request
  17. * (github/copilot-cli#2528). `supportsLocation('local')` returns false;
  18. * the orchestrator skips this target for local installs with a clear
  19. * message (same pattern as Codex).
  20. *
  21. * The file is machine-written by the CLI's own `/mcp add` flow, so it's
  22. * plain JSON — no JSONC handling needed; surgical edits go through the
  23. * shared read/mutate/write helpers (Cursor pattern), preserving sibling
  24. * servers.
  25. *
  26. * No instructions file (MCP `initialize` instructions are the single
  27. * source of truth, #529) and no permissions concept — `autoAllow` is
  28. * silently ignored.
  29. */
  30. import * as fs from 'fs';
  31. import * as path from 'path';
  32. import * as os from 'os';
  33. import {
  34. AgentTarget,
  35. DetectionResult,
  36. InstallOptions,
  37. Location,
  38. WriteResult,
  39. } from './types';
  40. import {
  41. getMcpServerConfig,
  42. jsonDeepEqual,
  43. readJsonFile,
  44. writeJsonFile,
  45. } from './shared';
  46. function configDir(): string {
  47. const override = process.env.COPILOT_HOME;
  48. if (override && override.trim().length > 0) return override;
  49. return path.join(os.homedir(), '.copilot');
  50. }
  51. function mcpConfigPath(): string {
  52. return path.join(configDir(), 'mcp-config.json');
  53. }
  54. /**
  55. * `~/.copilot` existing is NOT proof the CLI is installed: the VS Code
  56. * Copilot Chat extension drops MCP socket-handoff lock files into
  57. * `~/.copilot/ide/` on launch, so a machine with only the VS Code
  58. * extension still has the dir (with a lone `ide` entry). Count the dir
  59. * as a CLI footprint only when it holds anything besides `ide` — the
  60. * CLI writes `config.json` (and later `mcp-config.json`, history state)
  61. * on first run.
  62. */
  63. function cliConfigDirPresent(): boolean {
  64. let entries: string[];
  65. try {
  66. entries = fs.readdirSync(configDir());
  67. } catch {
  68. return false;
  69. }
  70. return entries.some((e) => e !== 'ide');
  71. }
  72. /**
  73. * Best-effort check that the `copilot` binary is reachable on PATH.
  74. * A plain fs scan (no shell-out) — cheap enough to run inside
  75. * `detectAll()` for the multiselect prompt.
  76. */
  77. function copilotOnPath(): boolean {
  78. const pathVar = process.env.PATH || '';
  79. const exts = process.platform === 'win32'
  80. ? ['.exe', '.cmd', '.bat', '.ps1']
  81. : [''];
  82. for (const dir of pathVar.split(path.delimiter)) {
  83. if (!dir) continue;
  84. for (const ext of exts) {
  85. try {
  86. if (fs.existsSync(path.join(dir, 'copilot' + ext))) return true;
  87. } catch { /* ignore unreadable PATH entries */ }
  88. }
  89. }
  90. return false;
  91. }
  92. function buildCopilotMcpConfig(): { type: string; command: string; args: string[]; tools: string[] } {
  93. const base = getMcpServerConfig();
  94. return { ...base, tools: ['*'] };
  95. }
  96. class CopilotCliTarget implements AgentTarget {
  97. readonly id = 'copilot-cli' as const;
  98. readonly displayName = 'GitHub Copilot CLI';
  99. readonly docsUrl = 'https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers';
  100. supportsLocation(loc: Location): boolean {
  101. return loc === 'global';
  102. }
  103. detect(loc: Location): DetectionResult {
  104. if (loc !== 'global') {
  105. return { installed: false, alreadyConfigured: false };
  106. }
  107. const file = mcpConfigPath();
  108. const config = readJsonFile(file);
  109. const alreadyConfigured = !!config.mcpServers?.codegraph;
  110. const installed = cliConfigDirPresent() || copilotOnPath();
  111. return { installed, alreadyConfigured, configPath: file };
  112. }
  113. install(loc: Location, _opts: InstallOptions): WriteResult {
  114. if (loc !== 'global') {
  115. return {
  116. files: [],
  117. notes: ['Copilot CLI has no project-local config — re-run with --location=global to install.'],
  118. };
  119. }
  120. return {
  121. files: [writeMcpEntry()],
  122. notes: ['Restart any running Copilot CLI session to pick up the MCP server.'],
  123. };
  124. }
  125. uninstall(loc: Location): WriteResult {
  126. if (loc !== 'global') return { files: [] };
  127. const file = mcpConfigPath();
  128. if (!fs.existsSync(file)) {
  129. return { files: [{ path: file, action: 'not-found' }] };
  130. }
  131. const config = readJsonFile(file);
  132. if (!config.mcpServers?.codegraph) {
  133. return { files: [{ path: file, action: 'not-found' }] };
  134. }
  135. delete config.mcpServers.codegraph;
  136. if (Object.keys(config.mcpServers).length === 0) {
  137. delete config.mcpServers;
  138. }
  139. if (Object.keys(config).length === 0) {
  140. // Nothing left but the `{}` we'd write back — delete the file so
  141. // uninstall fully reverses a from-scratch install. A leftover
  142. // empty file would keep detect() reporting the CLI as installed.
  143. fs.unlinkSync(file);
  144. } else {
  145. writeJsonFile(file, config);
  146. }
  147. return { files: [{ path: file, action: 'removed' }] };
  148. }
  149. printConfig(loc: Location): string {
  150. if (loc !== 'global') {
  151. return '# Copilot CLI has no project-local config — use --location=global.\n';
  152. }
  153. const snippet = JSON.stringify({ mcpServers: { codegraph: buildCopilotMcpConfig() } }, null, 2);
  154. return `# Add to ${mcpConfigPath()}\n\n${snippet}\n`;
  155. }
  156. describePaths(loc: Location): string[] {
  157. if (loc !== 'global') return [];
  158. return [mcpConfigPath()];
  159. }
  160. }
  161. function writeMcpEntry(): WriteResult['files'][number] {
  162. const file = mcpConfigPath();
  163. const existing = readJsonFile(file);
  164. const before = existing.mcpServers?.codegraph;
  165. const after = buildCopilotMcpConfig();
  166. if (jsonDeepEqual(before, after)) {
  167. return { path: file, action: 'unchanged' };
  168. }
  169. const existed = fs.existsSync(file);
  170. if (!existing.mcpServers) existing.mcpServers = {};
  171. existing.mcpServers.codegraph = after;
  172. writeJsonFile(file, existing);
  173. return { path: file, action: existed ? 'updated' : 'created' };
  174. }
  175. export const copilotCliTarget: AgentTarget = new CopilotCliTarget();