import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as readline from 'readline';
import { styleText } from 'util';
type AgentName = 'claude-code' | 'cursor' | 'github-copilot' | 'codex' | 'antigravity';
export interface AgentConfig {
name: AgentName;
displayName: string;
skillsDir: string;
markerDir: string;
}
export const AGENTS: readonly AgentConfig[] = [
{
name: 'claude-code',
displayName: 'Claude Code',
skillsDir: path.join('.claude', 'skills'),
markerDir: '.claude'
},
{
name: 'cursor',
displayName: 'Cursor',
skillsDir: path.join('.agents', 'skills'),
markerDir: '.cursor'
},
{
name: 'github-copilot',
displayName: 'GitHub Copilot',
skillsDir: path.join('.agents', 'skills'),
markerDir: '.copilot'
},
{
name: 'codex',
displayName: 'Codex',
skillsDir: path.join('.agents', 'skills'),
markerDir: '.codex'
},
{
name: 'antigravity',
displayName: 'Antigravity',
skillsDir: path.join('.agents', 'skills'),
markerDir: path.join('.gemini', 'antigravity')
}
];
const MANIFEST_FILE = '.taly-skills.json';
interface Manifest {
skills: string[];
}
interface AgentSyncResult {
agent: AgentName;
destDir: string;
added: string[];
updated: string[];
removed: string[];
}
export async function main(bundledSkillsDir: string): Promise<void> {
const detectedAgents = AGENTS.filter((agent) =>
fs.existsSync(path.join(os.homedir(), agent.markerDir))
);
if (detectedAgents.length === 0) {
console.error(
styleText(
'red',
'No supported coding agents detected. Install one of ' +
`${AGENTS.map((a) => a.displayName).join(', ')} first.`,
{
stream: process.stderr
}
)
);
process.exitCode = 1;
return;
}
const targetAgents = await promptForTargetAgents(detectedAgents);
if (targetAgents.length === 0) {
console.log('No agents selected. Nothing to do.');
return;
}
const results = installSkills(bundledSkillsDir, targetAgents);
printSummary(results);
}
const paint = (style: Parameters<typeof styleText>[0], text: string): string =>
styleText(style, text, { stream: process.stdout });
async function promptForTargetAgents(candidates: AgentConfig[]): Promise<AgentConfig[]> {
console.log(paint('bold', 'For which agent(s) do you want to install the skills?'));
candidates.forEach((agent, i) =>
console.log(`${paint('cyan', String(i + 1))}. ${agent.displayName}`)
);
const readlineInterface = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const answer = await new Promise<string>((resolve) =>
readlineInterface.question(
paint('dim', 'Enter numbers (comma-separated), or press Enter for all: '),
resolve
)
);
readlineInterface.close();
const trimmed = answer.trim();
if (trimmed === '') {
return candidates;
}
const selected = new Set<AgentConfig>();
for (const token of trimmed.split(',')) {
const index = Number.parseInt(token.trim(), 10) - 1;
if (index >= 0 && index < candidates.length) {
selected.add(candidates[index]);
}
}
return [...selected];
}
export function installSkills(
bundledSkillsDir: string,
targetAgents: readonly AgentConfig[]
): AgentSyncResult[] {
if (!fs.existsSync(bundledSkillsDir)) {
throw new Error(`Bundled skills folder not found at ${bundledSkillsDir}`);
}
const bundledSkills = fs
.readdirSync(bundledSkillsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
const workspaceRoot = findWorkspaceRoot(process.cwd());
const syncedByDir = new Map<string, Omit<AgentSyncResult, 'agent'>>();
const results: AgentSyncResult[] = [];
for (const agent of targetAgents) {
const destDir = path.join(workspaceRoot, agent.skillsDir);
// Several agents might share the same destination directory
// Sync every directory only once, but report results per requested agent.
let sync = syncedByDir.get(destDir);
if (!sync) {
sync = syncSkillsToDir(bundledSkillsDir, destDir, bundledSkills);
syncedByDir.set(destDir, sync);
}
results.push({ agent: agent.name, ...sync });
}
return results;
}
function findWorkspaceRoot(startDir: string): string {
let currentDir = startDir;
while (currentDir !== path.parse(currentDir).root) {
if (
fs.existsSync(path.join(currentDir, '.git')) ||
fs.existsSync(path.join(currentDir, '.vscode')) ||
fs.existsSync(path.join(currentDir, '.claude'))
) {
return currentDir;
}
currentDir = path.dirname(currentDir);
}
throw new Error(
'Workspace root not found. Ensure a .git, .vscode or .claude folder exists in the root.'
);
}
function mirrorDir(source: string, destination: string): void {
fs.mkdirSync(destination, { recursive: true });
const sourceEntries = fs.readdirSync(source, { withFileTypes: true });
const sourceNames = new Set(sourceEntries.map((entry) => entry.name));
for (const entry of sourceEntries) {
const sourcePath = path.join(source, entry.name);
const destinationPath = path.join(destination, entry.name);
if (entry.isDirectory()) {
mirrorDir(sourcePath, destinationPath);
} else {
fs.copyFileSync(sourcePath, destinationPath);
}
}
for (const existing of fs.readdirSync(destination)) {
if (!sourceNames.has(existing)) {
fs.rmSync(path.join(destination, existing), { recursive: true, force: true });
}
}
}
function syncSkillsToDir(
bundledSkillsDir: string,
destDir: string,
bundledSkills: string[]
): Omit<AgentSyncResult, 'agent'> {
fs.mkdirSync(destDir, { recursive: true });
const previousSkills = readManifest(destDir).skills;
const added: string[] = [];
const updated: string[] = [];
const removed: string[] = [];
for (const skill of bundledSkills) {
const dest = path.join(destDir, skill);
if (fs.existsSync(dest)) {
updated.push(skill);
} else {
added.push(skill);
}
mirrorDir(path.join(bundledSkillsDir, skill), dest);
}
for (const previous of previousSkills) {
if (!bundledSkills.includes(previous)) {
const stale = path.join(destDir, previous);
if (fs.existsSync(stale)) {
fs.rmSync(stale, { recursive: true, force: true });
removed.push(previous);
}
}
}
writeManifest(destDir, { skills: [...bundledSkills].sort() });
return { destDir, added, updated, removed };
}
function readManifest(destDir: string): Manifest {
const manifestPath = path.join(destDir, MANIFEST_FILE);
if (!fs.existsSync(manifestPath)) {
return { skills: [] };
}
try {
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Partial<Manifest>;
return { skills: Array.isArray(parsed.skills) ? parsed.skills : [] };
} catch {
return { skills: [] };
}
}
function writeManifest(destDir: string, manifest: Manifest) {
fs.writeFileSync(path.join(destDir, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
}
function printSummary(results: AgentSyncResult[]) {
for (const result of results) {
const count = (amount: number, label: string, style: Parameters<typeof styleText>[0]): string =>
paint(amount === 0 ? 'dim' : style, `${amount} ${label}`);
const parts = [
count(result.added.length, 'added', 'green'),
count(result.updated.length, 'updated', 'yellow'),
count(result.removed.length, 'removed', 'red')
];
const label = AGENTS.find((agent) => agent.name === result.agent)?.displayName ?? result.agent;
console.log(
`${paint('green', '✓')} ${paint('bold', label)} ${paint(
'dim',
`(${result.destDir})`
)} — ${parts.join(', ')}`
);
}
}