File

libs/core/skills/src/install-skills.ts

Index

Properties

Properties

displayName
Type string
markerDir
Type string
name
Type AgentName
skillsDir
Type string
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(', ')}`
    );
  }
}

results matching ""

    No results matching ""