Skip to content
Closed
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ bin/gstack-global-discover
.openclaw/
.hermes/
.gbrain/
.copilot/
.gbrain-source
.context/
extension/.auth.json
Expand Down
103 changes: 103 additions & 0 deletions hosts/copilot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { HostConfig } from '../scripts/host-config';

/**
* GitHub Copilot CLI host.
*
* Copilot CLI discovers custom agents as flat `.agent.md` files in
* `~/.copilot/agents/`. Each file has YAML frontmatter (name, description,
* tools, target, etc.) followed by markdown instructions. Invoke with
* `copilot --agent <name>`.
*
* Schema reference:
* https://docs.github.com/en/copilot/reference/custom-agents-configuration
*
* gstack skills are emitted as `gstack-<skill>.agent.md` (flat with prefix —
* Copilot CLI does not recurse into subdirectories under ~/.copilot/agents/).
*/
const copilot: HostConfig = {
name: 'copilot',
displayName: 'GitHub Copilot CLI',
cliCommand: 'copilot',

globalRoot: '.copilot/agents',
localSkillRoot: '.copilot/agents',
hostSubdir: '.copilot',
usesEnvVars: true,

outputLayout: 'flat-agent-md',

frontmatter: {
mode: 'allowlist',
keepFields: ['name', 'description'],
descriptionLimit: 1024,
descriptionLimitBehavior: 'truncate',
extraFields: {
// `target` is intentionally omitted — defaults to "both" (Copilot CLI + VS Code
// Copilot extension), maximising reach. Set to "github-copilot" or "vscode" to
// narrow if a deployment ever needs that.
// gstack skills need broad tool access. Emit as YAML array via stringified literal —
// transformFrontmatter does string-interpolation, so the value is rendered verbatim.
// (If transformFrontmatter ever gains real array support, switch to a JS array.)
tools: '["*"]',
},
},

generation: {
generateMetadata: false,
// Skipped because they don't fit Copilot CLI's stateless single-invocation
// agent model. They either toggle session state, configure other skills,
// or wrap a binary that should not recurse:
// 'codex' — wraps the `codex` CLI binary (every external host skips this)
// 'copilot' — would recurse: this skill wraps the `copilot` CLI itself
// 'freeze' — toggles a session-scoped edit boundary; agents are stateless
// 'unfreeze' — pairs with /freeze; same reason
// 'careful' — installs session-scoped destructive-command guardrails
// 'guard' — combines /careful + /freeze; same reason
// 'plan-tune' — interactive UI for tuning AskUserQuestion sensitivity
// per-skill; only meaningful inside a persistent skill system
// Follow-up worth filing: the rules from freeze/careful/guard could be
// injected into a project AGENTS.md template so they're ambient across
// every Copilot CLI invocation, recovering the protection we drop here.
skipSkills: ['codex', 'copilot', 'freeze', 'unfreeze', 'careful', 'guard', 'plan-tune'],
},

pathRewrites: [
// Copilot CLI installs are global-only — agents live in ~/.copilot/agents/
// and runtime support files (bin/, browse/) live in ~/.copilot/gstack/ via
// $GSTACK_ROOT. Both the `~/.claude/skills/gstack` references (absolute, in
// bash blocks) AND the `.claude/skills` references (project-local hints in
// prose) need to point at the same runtime root, since Copilot CLI doesn't
// currently have a per-workspace agents directory.
{ from: '~/.claude/skills/gstack', to: '$GSTACK_ROOT' },
{ from: '.claude/skills/gstack', to: '$GSTACK_ROOT' },
{ from: '.claude/skills', to: '$GSTACK_ROOT' },
],

suppressedResolvers: [
'DESIGN_OUTSIDE_VOICES',
'ADVERSARIAL_STEP',
'CODEX_SECOND_OPINION',
'CODEX_PLAN_REVIEW',
'REVIEW_ARMY',
'GBRAIN_CONTEXT_LOAD',
'GBRAIN_SAVE_RESULTS',
],

runtimeRoot: {
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
globalFiles: {
'review': ['checklist.md', 'TODOS-format.md'],
},
},

install: {
prefixable: false,
linkingStrategy: 'symlink-generated',
},

coAuthorTrailer: 'Co-Authored-By: GitHub Copilot <noreply@github.com>',
learningsMode: 'basic',
boundaryInstruction: 'IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. Ignore them. Stay focused on the repository code only.',
};

export default copilot;
5 changes: 3 additions & 2 deletions hosts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ import cursor from './cursor';
import openclaw from './openclaw';
import hermes from './hermes';
import gbrain from './gbrain';
import copilot from './copilot';

/** All registered host configs. Add new hosts here. */
export const ALL_HOST_CONFIGS: HostConfig[] = [claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes, gbrain];
export const ALL_HOST_CONFIGS: HostConfig[] = [claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes, gbrain, copilot];

/** Map from host name to config. */
export const HOST_CONFIG_MAP: Record<string, HostConfig> = Object.fromEntries(
Expand Down Expand Up @@ -65,4 +66,4 @@ export function getExternalHosts(): HostConfig[] {
}

// Re-export individual configs for direct import
export { claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes, gbrain };
export { claude, codex, factory, kiro, opencode, slate, cursor, openclaw, hermes, gbrain, copilot };
14 changes: 11 additions & 3 deletions scripts/gen-skill-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,9 +349,17 @@ function processExternalHost(
const hostConfig = getHostConfig(host);

const name = externalSkillName(skillDir === '.' ? '' : skillDir, frontmatterName);
const outputDir = path.join(ROOT, hostConfig.hostSubdir, 'skills', name);
fs.mkdirSync(outputDir, { recursive: true });
const outputPath = path.join(outputDir, 'SKILL.md');
let outputDir: string;
let outputPath: string;
if (hostConfig.outputLayout === 'flat-agent-md') {
outputDir = path.join(ROOT, hostConfig.hostSubdir, 'agents');
fs.mkdirSync(outputDir, { recursive: true });
outputPath = path.join(outputDir, `${name}.agent.md`);
} else {
outputDir = path.join(ROOT, hostConfig.hostSubdir, 'skills', name);
fs.mkdirSync(outputDir, { recursive: true });
outputPath = path.join(outputDir, 'SKILL.md');
}

// Guard against symlink loops
let symlinkLoop = false;
Expand Down
9 changes: 9 additions & 0 deletions scripts/host-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ export interface HostConfig {
includeSkills?: string[];
};

/**
* Output layout for generated skill docs.
* - 'per-skill-dir' (default): <hostSubdir>/skills/<name>/SKILL.md
* Used by Codex, Cursor, Factory, OpenCode, etc.
* - 'flat-agent-md': <hostSubdir>/agents/<name>.agent.md
* Used by GitHub Copilot CLI (~/.copilot/agents/<name>.agent.md).
*/
outputLayout?: 'per-skill-dir' | 'flat-agent-md';

// --- Content Rewrites ---
/** Literal string replacements on generated SKILL.md content. Order matters, replaceAll. */
pathRewrites: Array<{ from: string; to: string }>;
Expand Down
58 changes: 54 additions & 4 deletions setup
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ FACTORY_SKILLS="$HOME/.factory/skills"
FACTORY_GSTACK="$FACTORY_SKILLS/gstack"
OPENCODE_SKILLS="$HOME/.config/opencode/skills"
OPENCODE_GSTACK="$OPENCODE_SKILLS/gstack"
COPILOT_AGENTS="$HOME/.copilot/agents"
COPILOT_GSTACK="$HOME/.copilot/gstack"

IS_WINDOWS=0
case "$(uname -s)" in
Expand All @@ -43,7 +45,7 @@ TEAM_MODE=0
NO_TEAM_MODE=0
while [ $# -gt 0 ]; do
case "$1" in
--host) [ -z "$2" ] && echo "Missing value for --host (expected claude, codex, kiro, factory, opencode, openclaw, hermes, gbrain, or auto)" >&2 && exit 1; HOST="$2"; shift 2 ;;
--host) [ -z "$2" ] && echo "Missing value for --host (expected claude, codex, kiro, factory, opencode, copilot, openclaw, hermes, gbrain, or auto)" >&2 && exit 1; HOST="$2"; shift 2 ;;
--host=*) HOST="${1#--host=}"; shift ;;
--local) LOCAL_INSTALL=1; shift ;;
--prefix) SKILL_PREFIX=1; SKILL_PREFIX_FLAG=1; shift ;;
Expand All @@ -56,7 +58,7 @@ while [ $# -gt 0 ]; do
done

case "$HOST" in
claude|codex|kiro|factory|opencode|auto) ;;
claude|codex|kiro|factory|opencode|copilot|auto) ;;
openclaw)
echo ""
echo "OpenClaw integration uses a different model — OpenClaw spawns Claude Code"
Expand Down Expand Up @@ -91,7 +93,7 @@ case "$HOST" in
echo "GBrain setup and brain skills ship from the GBrain repo."
echo ""
exit 0 ;;
*) echo "Unknown --host value: $HOST (expected claude, codex, kiro, factory, opencode, openclaw, hermes, gbrain, or auto)" >&2; exit 1 ;;
*) echo "Unknown --host value: $HOST (expected claude, codex, kiro, factory, opencode, copilot, openclaw, hermes, gbrain, or auto)" >&2; exit 1 ;;
esac

# ─── Resolve skill prefix preference ─────────────────────────
Expand Down Expand Up @@ -155,14 +157,16 @@ INSTALL_CODEX=0
INSTALL_KIRO=0
INSTALL_FACTORY=0
INSTALL_OPENCODE=0
INSTALL_COPILOT=0
if [ "$HOST" = "auto" ]; then
command -v claude >/dev/null 2>&1 && INSTALL_CLAUDE=1
command -v codex >/dev/null 2>&1 && INSTALL_CODEX=1
command -v kiro-cli >/dev/null 2>&1 && INSTALL_KIRO=1
command -v droid >/dev/null 2>&1 && INSTALL_FACTORY=1
command -v opencode >/dev/null 2>&1 && INSTALL_OPENCODE=1
command -v copilot >/dev/null 2>&1 && INSTALL_COPILOT=1
# If none found, default to claude
if [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ]; then
if [ "$INSTALL_CLAUDE" -eq 0 ] && [ "$INSTALL_CODEX" -eq 0 ] && [ "$INSTALL_KIRO" -eq 0 ] && [ "$INSTALL_FACTORY" -eq 0 ] && [ "$INSTALL_OPENCODE" -eq 0 ] && [ "$INSTALL_COPILOT" -eq 0 ]; then
INSTALL_CLAUDE=1
fi
elif [ "$HOST" = "claude" ]; then
Expand All @@ -175,6 +179,8 @@ elif [ "$HOST" = "factory" ]; then
INSTALL_FACTORY=1
elif [ "$HOST" = "opencode" ]; then
INSTALL_OPENCODE=1
elif [ "$HOST" = "copilot" ]; then
INSTALL_COPILOT=1
fi

migrate_direct_codex_install() {
Expand Down Expand Up @@ -321,6 +327,16 @@ if [ "$INSTALL_OPENCODE" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
)
fi

# 1e. Generate .copilot/ GitHub Copilot CLI agent files
if [ "$INSTALL_COPILOT" -eq 1 ] && [ "$NEEDS_BUILD" -eq 0 ]; then
log "Generating .copilot/ agent files..."
(
cd "$SOURCE_GSTACK_DIR"
bun install --frozen-lockfile 2>/dev/null || bun install
bun run gen:skill-docs --host copilot
)
fi

# 2. Ensure Playwright's Chromium is available
if ! ensure_playwright_browser; then
echo "Installing Playwright Chromium..."
Expand Down Expand Up @@ -935,6 +951,40 @@ if [ "$INSTALL_OPENCODE" -eq 1 ]; then
echo " opencode skills: $OPENCODE_SKILLS"
fi

# 6d. Install for GitHub Copilot CLI
# Copilot discovers agents as flat `.agent.md` files under ~/.copilot/agents/.
# We symlink each generated `gstack-<skill>.agent.md` from the repo's
# .copilot/agents/ dir, and create ~/.copilot/gstack/ with bin/browse symlinks
# so agents can resolve $GSTACK_ROOT-relative tooling.
if [ "$INSTALL_COPILOT" -eq 1 ]; then
mkdir -p "$COPILOT_AGENTS"
mkdir -p "$COPILOT_GSTACK"
# Clean stale gstack symlinks first so renamed/removed skills don't leave
# orphan entries pointing at non-existent source files.
find "$COPILOT_AGENTS" -maxdepth 1 -name "gstack-*.agent.md" -type l -delete 2>/dev/null || true
# Symlink each agent file (flat layout — Copilot CLI does not recurse)
for f in "$SOURCE_GSTACK_DIR"/.copilot/agents/*.agent.md; do
[ -e "$f" ] || continue # tolerate no matches
ln -sf "$f" "$COPILOT_AGENTS/$(basename "$f")"
done
# Runtime support files referenced by agent prompts via $GSTACK_ROOT
ln -sfn "$SOURCE_GSTACK_DIR/bin" "$COPILOT_GSTACK/bin"
ln -sfn "$SOURCE_GSTACK_DIR/browse/dist" "$COPILOT_GSTACK/browse-dist"
ln -sfn "$SOURCE_GSTACK_DIR/gstack-upgrade" "$COPILOT_GSTACK/gstack-upgrade"
[ -f "$SOURCE_GSTACK_DIR/ETHOS.md" ] && ln -sfn "$SOURCE_GSTACK_DIR/ETHOS.md" "$COPILOT_GSTACK/ETHOS.md"
AGENT_COUNT=$(ls "$COPILOT_AGENTS"/gstack-*.agent.md 2>/dev/null | wc -l | tr -d ' ')
echo "gstack ready (copilot)."
echo " browse: $BROWSE_BIN"
echo " copilot agents: $COPILOT_AGENTS ($AGENT_COUNT gstack-*.agent.md files)"
echo " runtime root: $COPILOT_GSTACK"
echo ""
echo " IMPORTANT: agents reference \$GSTACK_ROOT — set it before launching copilot:"
echo " export GSTACK_ROOT=$COPILOT_GSTACK"
echo " Or persist in ~/.copilot/settings.json under secret-env-vars."
echo ""
echo " Invoke any gstack agent: copilot --agent gstack-<skill> \"<prompt>\""
fi

# 7. Create .agents/ sidecar symlinks for the real Codex skill target.
# The root Codex skill ends up pointing at $SOURCE_GSTACK_DIR/.agents/skills/gstack,
# so the runtime assets must live there for both global and repo-local installs.
Expand Down
51 changes: 30 additions & 21 deletions test/gen-skill-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2069,7 +2069,25 @@ import { ALL_HOST_CONFIGS, getExternalHosts } from '../hosts/index';
describe('Parameterized host smoke tests', () => {
for (const hostConfig of getExternalHosts()) {
describe(`${hostConfig.displayName} (--host ${hostConfig.name})`, () => {
const hostDir = path.join(ROOT, hostConfig.hostSubdir, 'skills');
// Layout-aware helpers — `flat-agent-md` hosts (Copilot CLI) emit
// <hostSubdir>/agents/<name>.agent.md; default hosts emit
// <hostSubdir>/skills/<name>/SKILL.md.
const isFlat = hostConfig.outputLayout === 'flat-agent-md';
const hostDir = path.join(ROOT, hostConfig.hostSubdir, isFlat ? 'agents' : 'skills');
const skillFilePath = (name: string) => isFlat
? path.join(hostDir, `${name}.agent.md`)
: path.join(hostDir, name, 'SKILL.md');
const listSkillFiles = (): Array<{ name: string; path: string }> => {
if (!fs.existsSync(hostDir)) return [];
if (isFlat) {
return fs.readdirSync(hostDir)
.filter(f => f.endsWith('.agent.md'))
.map(f => ({ name: f.replace(/\.agent\.md$/, ''), path: path.join(hostDir, f) }));
}
return fs.readdirSync(hostDir)
.filter(d => fs.existsSync(path.join(hostDir, d, 'SKILL.md')))
.map(d => ({ name: d, path: path.join(hostDir, d, 'SKILL.md') }));
};

test('generates output that exists on disk', () => {
// Generated dir should exist (created by earlier bun run gen:skill-docs --host all)
Expand All @@ -2080,37 +2098,27 @@ describe('Parameterized host smoke tests', () => {
});
}
expect(fs.existsSync(hostDir)).toBe(true);
const skills = fs.readdirSync(hostDir).filter(d =>
fs.existsSync(path.join(hostDir, d, 'SKILL.md'))
);
expect(skills.length).toBeGreaterThan(0);
expect(listSkillFiles().length).toBeGreaterThan(0);
});

test('no .claude/skills path leakage outside repo-root sidecar symlinks', () => {
if (!fs.existsSync(hostDir)) return; // skip if not generated
const skills = fs.readdirSync(hostDir);
for (const skill of skills) {
for (const { name, path: skillMd } of listSkillFiles()) {
// Dev installs may mount the repo root at host/skills/gstack as a runtime
// sidecar. The generator skips that symlink loop, so leakage checks should too.
if (isRepoRootSymlink(path.join(hostDir, skill))) continue;
const skillMd = path.join(hostDir, skill, 'SKILL.md');
if (!fs.existsSync(skillMd)) continue;
if (!isFlat && isRepoRootSymlink(path.join(hostDir, name))) continue;
const content = fs.readFileSync(skillMd, 'utf-8');
// Strip bash blocks (which have legitimate fallback paths)
const noBash = content.replace(/```bash\n[\s\S]*?```/g, '');
const leaks = noBash.split('\n').filter(l => l.includes('.claude/skills'));
if (leaks.length > 0) {
throw new Error(`${skill}: .claude/skills leakage:\n${leaks.slice(0, 3).join('\n')}`);
throw new Error(`${name}: .claude/skills leakage:\n${leaks.slice(0, 3).join('\n')}`);
}
}
});

test('frontmatter has name and description', () => {
if (!fs.existsSync(hostDir)) return;
const skills = fs.readdirSync(hostDir);
for (const skill of skills) {
const skillMd = path.join(hostDir, skill, 'SKILL.md');
if (!fs.existsSync(skillMd)) continue;
for (const { path: skillMd } of listSkillFiles()) {
const content = fs.readFileSync(skillMd, 'utf-8');
expect(content).toMatch(/^---\n/);
expect(content).toMatch(/^name:\s/m);
Expand All @@ -2119,7 +2127,7 @@ describe('Parameterized host smoke tests', () => {
});

test('generates Claude outside-voice skill for external hosts', () => {
const skillMd = path.join(hostDir, 'gstack-claude', 'SKILL.md');
const skillMd = skillFilePath('gstack-claude');
expect(fs.existsSync(skillMd)).toBe(true);
const content = fs.readFileSync(skillMd, 'utf-8');
expect(content).toContain('claude -p');
Expand All @@ -2140,7 +2148,7 @@ describe('Parameterized host smoke tests', () => {

if (hostConfig.generation.skipSkills?.includes('codex')) {
test('/codex skill excluded', () => {
expect(fs.existsSync(path.join(hostDir, 'gstack-codex', 'SKILL.md'))).toBe(false);
expect(fs.existsSync(skillFilePath('gstack-codex'))).toBe(false);
});
}
});
Expand All @@ -2159,7 +2167,8 @@ describe('--host all', () => {
// All hosts should appear in output
expect(output).toContain('FRESH: SKILL.md'); // claude
for (const hostConfig of getExternalHosts()) {
expect(output).toContain(`FRESH: ${hostConfig.hostSubdir}/skills/`);
const subdir = hostConfig.outputLayout === 'flat-agent-md' ? 'agents' : 'skills';
expect(output).toContain(`FRESH: ${hostConfig.hostSubdir}/${subdir}/`);
}
});
});
Expand Down Expand Up @@ -2270,9 +2279,9 @@ describe('setup script validation', () => {
expect(fnBody).toContain('rm -f "$target"');
});

test('setup supports --host auto|claude|codex|kiro|opencode', () => {
test('setup supports --host auto|claude|codex|kiro|opencode|copilot', () => {
expect(setupContent).toContain('--host');
expect(setupContent).toContain('claude|codex|kiro|factory|opencode|auto');
expect(setupContent).toContain('claude|codex|kiro|factory|opencode|copilot|auto');
});

test('auto mode detects claude, codex, kiro, and opencode binaries', () => {
Expand Down
Loading