Skip to content

Commit 2b0f2df

Browse files
committed
Add GitHub Copilot CLI support
1 parent 33398f3 commit 2b0f2df

22 files changed

+1065
-33
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ Foolery launches and monitors agent sessions through their CLIs. It auto-detects
109109
| Agent | CLI Command | Notes |
110110
|-------|-------------|-------|
111111
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Default dialect. Streams JSONL via `--output-format stream-json`. |
112+
| [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/use-the-github-copilot-coding-agent-in-the-cli) | `copilot` | Uses prompt mode with JSON streaming and supports Claude, GPT, and Gemini model selections. |
112113
| [Codex](https://github.com/openai/codex) | `codex` | Uses `exec` subcommand with `--json` output. ChatGPT CLI variants also supported. |
113114
| [OpenCode](https://github.com/opencode-ai/opencode) | `opencode` | Uses `run` subcommand with `--format json` output. |
114115
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | Auto-detected and displayed in agent identity. |

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@
1111
"build:runtime": "bash scripts/build-runtime-artifact.sh",
1212
"release": "bash scripts/release.sh",
1313
"release:ci": "bash scripts/release-from-package-version.sh",
14-
"start": "next start",
14+
"start": "next start",
1515
"lint": "eslint",
1616
"test": "vitest run --project unit",
1717
"test:storybook": "vitest run --project storybook",
1818
"test:all": "vitest run",
1919
"test:coverage": "vitest run --project unit --coverage",
20+
"test:smoke:copilot:ui": "bash scripts/test-copilot-settings-ui.sh",
21+
"test:smoke:copilot:install": "bash scripts/test-install-copilot-setup.sh",
2022
"storybook": "storybook dev -p 6006",
2123
"build-storybook": "storybook build"
2224
},
@@ -48,7 +50,7 @@
4850
"@storybook/addon-onboarding": "^10.2.8",
4951
"@storybook/addon-vitest": "^10.2.8",
5052
"@storybook/nextjs-vite": "^10.2.8",
51-
"@tailwindcss/postcss": "^4",
53+
"@tailwindcss/postcss": "^4",
5254
"@types/bun": "^1.3.8",
5355
"@types/node": "^20",
5456
"@types/react": "^19",

scripts/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ Build, release, and setup scripts for Foolery.
1010
- **`build-runtime-artifact.sh`** — Build a self-contained runtime artifact for distribution
1111
- **`release.sh`** — Cut a release from the current state
1212
- **`release-from-package-version.sh`** — Tag and release using the version from `package.json`
13-
- **`agent-wizard.sh`**Interactive wizard for configuring agent settings
13+
- **`agent-wizard.sh`**Legacy interactive wizard for configuring agent settings
1414
- **`setup-beats-dolt-hooks.sh`** — Install Dolt-native git hooks for Beads sync
1515
- **`check-coverage.mjs`** — Verify test coverage thresholds
1616
- **`test-doctor-stream.sh`** — Smoke test for the doctor streaming endpoint
1717
- **`test-start-restart-settings.sh`** — Smoke test for settings load on startup
18+
- **`test-copilot-settings-ui.sh`** — Browser smoke test for Copilot scan/import in Settings
19+
- **`test-install-copilot-setup.sh`** — Isolated installer smoke test for Copilot setup
20+
- **`test-fixtures/`** — Deterministic offline fixtures for smoke tests
1821

1922
## Subdirectories
2023

scripts/agent-wizard.sh

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ CONFIG_DIR="${HOME}/.config/foolery"
99
SETTINGS_FILE="${CONFIG_DIR}/settings.toml"
1010

1111
# Known agent ids checked during detection.
12-
KNOWN_AGENTS=(claude codex gemini openrouter)
12+
KNOWN_AGENTS=(claude copilot codex gemini opencode)
1313

1414
_wizard_supports_color() {
1515
if [[ -n "${NO_COLOR:-}" || -n "${CI:-}" || "${TERM:-}" == "dumb" ]]; then
@@ -92,9 +92,37 @@ _wizard_prompt() {
9292
_kv_set() { eval "_KV_${1}__${2}=\$3"; }
9393
_kv_get() { eval "printf '%s' \"\${_KV_${1}__${2}:-\$3}\""; }
9494

95+
_configured_model() {
96+
local aid="$1"
97+
case "$aid" in
98+
copilot)
99+
local config="$HOME/.copilot/config.json"
100+
if [[ -f "$config" ]]; then
101+
if command -v jq >/dev/null 2>&1; then
102+
jq -r '.model // .defaultModel // .selectedModel // empty' \
103+
"$config" 2>/dev/null
104+
else
105+
sed -nE \
106+
's/.*"(model|defaultModel|selectedModel)"[[:space:]]*:[[:space:]]*"([^"]*)".*/\2/p' \
107+
"$config" | head -n 1
108+
fi
109+
fi
110+
;;
111+
esac
112+
}
113+
95114
_discover_models() {
96115
local aid="$1"
97116
case "$aid" in
117+
copilot)
118+
_configured_model "$aid"
119+
printf '%s\n' \
120+
claude-sonnet-4.5 \
121+
claude-haiku-4.5 \
122+
gpt-5.3-codex \
123+
gpt-5.2 \
124+
gemini-2.5-pro
125+
;;
98126
codex)
99127
local cache="$HOME/.codex/models_cache.json"
100128
if [[ -f "$cache" ]]; then
@@ -118,9 +146,10 @@ _discover_models() {
118146
_agent_label() {
119147
case "$1" in
120148
claude) printf 'Claude Code' ;;
149+
copilot) printf 'GitHub Copilot' ;;
121150
codex) printf 'OpenAI Codex' ;;
122151
gemini) printf 'Google Gemini' ;;
123-
openrouter) printf 'OpenRouter' ;;
152+
opencode) printf 'OpenCode' ;;
124153
*) printf '%s' "$1" ;;
125154
esac
126155
}
@@ -318,6 +347,11 @@ maybe_agent_wizard() {
318347

319348
if [[ ${#FOUND_AGENTS[@]} -eq 1 ]]; then
320349
local sole="${FOUND_AGENTS[0]}"
350+
local detected_model
351+
detected_model="$(_configured_model "$sole")"
352+
if [[ -n "$detected_model" ]]; then
353+
_kv_set AGENT_MODELS "$sole" "$detected_model"
354+
fi
321355
local action
322356
for action in take scene breakdown; do
323357
_kv_set ACTION_MAP "$action" "$sole"

scripts/install.sh

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ RELEASE_REPO="${FOOLERY_RELEASE_REPO:-foolery}"
1212
RELEASE_TAG="${FOOLERY_RELEASE_TAG:-latest}"
1313
ASSET_BASENAME="${FOOLERY_ASSET_BASENAME:-foolery-runtime}"
1414
ARTIFACT_URL="${FOOLERY_ARTIFACT_URL:-}"
15+
SETUP_URL="${FOOLERY_SETUP_URL:-}"
1516

1617
_supports_color() {
1718
local fd="${1:-1}"
@@ -207,6 +208,7 @@ URL="\${FOOLERY_URL:-http://\$HOST:\$PORT}"
207208
RELEASE_OWNER="\${FOOLERY_RELEASE_OWNER:-$RELEASE_OWNER}"
208209
RELEASE_REPO="\${FOOLERY_RELEASE_REPO:-$RELEASE_REPO}"
209210
RELEASE_TAG="\${FOOLERY_RELEASE_TAG:-latest}"
211+
SETUP_URL="\${FOOLERY_SETUP_URL:-$SETUP_URL}"
210212
UPDATE_CHECK_ENABLED="\${FOOLERY_UPDATE_CHECK:-1}"
211213
UPDATE_CHECK_INTERVAL_SECONDS="\${FOOLERY_UPDATE_CHECK_INTERVAL_SECONDS:-21600}"
212214
UPDATE_CHECK_FILE="\${FOOLERY_UPDATE_CHECK_FILE:-\$STATE_DIR/update-check.cache}"
@@ -935,7 +937,10 @@ setup_cmd() {
935937
require_cmd curl
936938
937939
local setup_url
938-
setup_url="https://raw.githubusercontent.com/\$RELEASE_OWNER/\$RELEASE_REPO/main/scripts/setup.sh"
940+
setup_url="\$SETUP_URL"
941+
if [[ -z "\$setup_url" ]]; then
942+
setup_url="https://raw.githubusercontent.com/\$RELEASE_OWNER/\$RELEASE_REPO/main/scripts/setup.sh"
943+
fi
939944
940945
local tmp_setup
941946
tmp_setup="\$(mktemp "\${TMPDIR:-/tmp}/foolery-setup.XXXXXX")"

scripts/setup.sh

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,37 @@ _setup_confirm() {
142142
_kv_set() { eval "_KV_${1}__${2}=\$3"; }
143143
_kv_get() { eval "printf '%s' \"\${_KV_${1}__${2}:-\$3}\""; }
144144

145+
_configured_model() {
146+
local aid="$1"
147+
case "$aid" in
148+
copilot)
149+
local config="$HOME/.copilot/config.json"
150+
if [[ -f "$config" ]]; then
151+
if command -v jq >/dev/null 2>&1; then
152+
jq -r '.model // .defaultModel // .selectedModel // empty' \
153+
"$config" 2>/dev/null
154+
else
155+
sed -nE \
156+
's/.*"(model|defaultModel|selectedModel)"[[:space:]]*:[[:space:]]*"([^"]*)".*/\2/p' \
157+
"$config" | head -n 1
158+
fi
159+
fi
160+
;;
161+
esac
162+
}
163+
145164
_discover_models() {
146165
local aid="$1"
147166
case "$aid" in
167+
copilot)
168+
_configured_model "$aid"
169+
printf '%s\n' \
170+
claude-sonnet-4.5 \
171+
claude-haiku-4.5 \
172+
gpt-5.3-codex \
173+
gpt-5.2 \
174+
gemini-2.5-pro
175+
;;
148176
codex)
149177
local cache="$HOME/.codex/models_cache.json"
150178
if [[ -f "$cache" ]]; then
@@ -516,11 +544,12 @@ _repo_wizard() {
516544

517545
_AGENT_CONFIG_DIR="${HOME}/.config/foolery"
518546
_AGENT_SETTINGS_FILE="${_AGENT_CONFIG_DIR}/settings.toml"
519-
KNOWN_AGENTS=(claude codex gemini opencode)
547+
KNOWN_AGENTS=(claude copilot codex gemini opencode)
520548

521549
_agent_label() {
522550
case "$1" in
523551
claude) printf 'Claude Code' ;;
552+
copilot) printf 'GitHub Copilot' ;;
524553
codex) printf 'OpenAI Codex' ;;
525554
gemini) printf 'Google Gemini' ;;
526555
opencode) printf 'OpenCode' ;;
@@ -697,6 +726,11 @@ _agent_wizard() {
697726

698727
if [[ ${#FOUND_AGENTS[@]} -eq 1 ]]; then
699728
local sole="${FOUND_AGENTS[0]}"
729+
local detected_model
730+
detected_model="$(_configured_model "$sole")"
731+
if [[ -n "$detected_model" ]]; then
732+
_kv_set AGENT_MODELS "$sole" "$detected_model"
733+
fi
700734
local action
701735
for action in take scene breakdown; do
702736
_kv_set ACTION_MAP "$action" "$sole"
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import assert from "node:assert/strict";
2+
import { readFile } from "node:fs/promises";
3+
import { chromium } from "playwright";
4+
5+
const [url, settingsFile] = process.argv.slice(2);
6+
7+
if (!url || !settingsFile) {
8+
throw new Error(
9+
"Usage: node scripts/test-copilot-settings-ui.mjs <url> <settings-file>",
10+
);
11+
}
12+
13+
async function fetchJson(page, path) {
14+
return await page.evaluate(async (targetPath) => {
15+
const res = await fetch(targetPath);
16+
return await res.json();
17+
}, path);
18+
}
19+
20+
let browser;
21+
22+
try {
23+
browser = await chromium.launch();
24+
} catch (error) {
25+
const message = error instanceof Error
26+
? error.message
27+
: String(error);
28+
throw new Error(
29+
`${message}\nInstall Chromium with: bunx playwright install chromium`,
30+
);
31+
}
32+
33+
try {
34+
const page = await browser.newPage();
35+
await page.goto(url, { waitUntil: "domcontentloaded" });
36+
37+
await page.getByTitle("Settings").waitFor();
38+
await page.getByTitle("Settings").click();
39+
await page.getByRole("tab", { name: "Agents" }).click();
40+
await page.getByRole("button", { name: "Scan" }).click();
41+
42+
const scanResults = page
43+
.getByText("Scan Results")
44+
.locator("xpath=ancestor::div[contains(@class,'rounded-xl')][1]");
45+
await scanResults.waitFor();
46+
47+
const copilotRow = scanResults
48+
.getByText("copilot")
49+
.locator(
50+
"xpath=ancestor::div[contains(@class,'rounded-lg')][1]",
51+
);
52+
await copilotRow.waitFor();
53+
await copilotRow.getByText("Claude Sonnet 4.5").waitFor();
54+
await copilotRow.getByRole("button", { name: "Add" }).click();
55+
await copilotRow.getByText("registered").waitFor();
56+
57+
const agentsPayload = await fetchJson(
58+
page,
59+
"/api/settings/agents",
60+
);
61+
assert.equal(agentsPayload.ok, true);
62+
const agentEntry = Object.entries(
63+
agentsPayload.data ?? {},
64+
).find(([id]) => id.startsWith("copilot"));
65+
assert.ok(agentEntry, "expected a registered Copilot agent");
66+
67+
const [agentId, agent] = agentEntry;
68+
assert.equal(agent.model, "claude-sonnet-4.5");
69+
70+
let actionsPayload = await fetchJson(
71+
page,
72+
"/api/settings/actions",
73+
);
74+
for (let attempt = 0; attempt < 20; attempt += 1) {
75+
if (actionsPayload.data?.take === agentId) break;
76+
await page.waitForTimeout(250);
77+
actionsPayload = await fetchJson(
78+
page,
79+
"/api/settings/actions",
80+
);
81+
}
82+
assert.equal(actionsPayload.ok, true);
83+
assert.deepEqual(actionsPayload.data, {
84+
take: agentId,
85+
scene: agentId,
86+
breakdown: agentId,
87+
scopeRefinement: agentId,
88+
});
89+
90+
const settingsRaw = await readFile(settingsFile, "utf8");
91+
assert.match(settingsRaw, new RegExp(`\\[agents\\.${agentId}\\]`));
92+
assert.match(settingsRaw, /model = "claude-sonnet-4\.5"/);
93+
} finally {
94+
await browser?.close();
95+
}

0 commit comments

Comments
 (0)