Browse Source

feat: add agent-sync installer for skills and agents

Cross-platform, zero-dependency CLI: copies bundled skills and agents into ~/.agents, links them into every detected agent (junction/symlink/hardlink/copy fallback chain), creates pi manifests only when absent. Includes README translation and install docs.
softword 1 month ago
parent
commit
dd07dad177
3 changed files with 342 additions and 35 deletions
  1. 55 35
      README.md
  2. 270 0
      bin/agent-sync.mjs
  3. 17 0
      package.json

+ 55 - 35
README.md

@@ -1,64 +1,84 @@
 # oh-my-skills
 
-Skill per il [Pi Coding Agent](https://www.npmjs.com/package/@earendil-works/pi-coding-agent).
+Skills for the [Pi Coding Agent](https://www.npmjs.com/package/@earendil-works/pi-coding-agent).
 
-## Installazione con `npx skills`
+## Installation with `npx skills`
 
-Il CLI [skills](https://github.com/vercel-labs/skills) clona il repo, rileva automaticamente pi
-e installa le skill che scegli.
+The [skills](https://github.com/vercel-labs/skills) CLI clones the repo, automatically detects pi,
+and installs the skills you choose.
 
 ```bash
 npx skills add https://gogs.gvoci.com/softword/oh-my-skills.git
 ```
 
-Varianti utili:
+Useful variants:
 
 ```bash
-# Elenca le skill disponibili senza installare nulla
+# List available skills without installing anything
 npx skills add https://gogs.gvoci.com/softword/oh-my-skills.git -l
 
-# Installa solo alcune skill
+# Install only some skills
 npx skills add https://gogs.gvoci.com/softword/oh-my-skills.git -s commit -s coding-standard-rules
 
-# Installa a livello globale (tutti i progetti) invece che nel progetto corrente
+# Install globally (all projects) instead of the current project
 npx skills add https://gogs.gvoci.com/softword/oh-my-skills.git -g
 
-# Installa TUTTE le skill senza conferme
+# Install ALL skills without confirmations
 npx skills add https://gogs.gvoci.com/softword/oh-my-skills.git --all
 
-# Copia i file invece di creare symlink
+# Copy files instead of creating symlinks
 npx skills add https://gogs.gvoci.com/softword/oh-my-skills.git --copy
 ```
 
-> ⚠️ Il repository è privato: serve un accesso Git configurato su `gogs.gvoci.com`
-> (credenziali salvate o chiave SSH), altrimenti il clone fallisce.
+> ⚠️ The repository is private: Git access to `gogs.gvoci.com` must be configured
+> (saved credentials or SSH key), otherwise the clone fails.
 
-Aggiornamenti e rimozione:
+Updates and removal:
 
 ```bash
-npx skills update        # aggiorna le skill installate (progetto)
-npx skills update -g     # aggiorna solo le skill installate a livello globale
-npx skills remove        # rimuove le skill (guidato)
-npx skills remove -g     # rimuove le skill installate a livello globale
-npx skills ls            # elenca le skill installate
+npx skills update        # update installed skills (project)
+npx skills update -g     # update only globally installed skills
+npx skills remove        # remove skills (guided)
+npx skills remove -g     # remove globally installed skills
+npx skills ls            # list installed skills
 ```
 
-## Catalogo delle skill
+## Install everything with `agent-sync`
 
-| Skill | A cosa serve |
+`agent-sync` copies the skills and the agent prompts into the canonical `~/.agents/`
+directory and links them into every AI agent detected on the machine
+(Claude Code, pi, Codex, OpenCode, Cursor, Gemini, Trae, Windsurf, Aider Desk).
+On Windows it uses junctions for skill directories and degrades gracefully
+(hardlink, then copy) for agent files; on macOS/Linux it uses relative symlinks.
+
+```bash
+# one-shot, nothing persisted
+npx git+https://gogs.gvoci.com/softword/oh-my-skills.git
+
+# or install the command globally
+npm install -g git+https://gogs.gvoci.com/softword/oh-my-skills.git
+agent-sync
+
+# dry-run: report drift without writing anything (exit 1 when out of sync)
+agent-sync --check
+```
+
+## Skill catalog
+
+| Skill | What it is for |
 |---|---|
-| `add-phpengine-plugin` | Crea o modifica plugin PHP per il backend phpengine, seguendo la ricetta ufficiale |
-| `ascii-preview-generator` | Anteprime visive ASCII di interfacce, layout e componenti UI |
-| `codebase-analysis` | Analizza e documenta l'architettura di un codebase esistente senza modificarlo |
-| `code-simplifier` | Semplifica e refattorizza il codice preservandone il comportamento |
-| `coding-standard-rules` | Regole di qualità del codice applicate a ogni attività di scrittura/modifica |
-| `commit` | Commit atomici in stile Conventional Commits + aggiornamento CHANGELOG |
-| `create-docs` | Genera la documentazione strutturata del progetto (AGENTS.md, specs/) |
-| `format-report-fix` | Formato standard per riportare le fix con impatto concreto |
-| `frontend-mobile-rules` | Regole frontend mobile-first: MUI, viewport e safe-area iOS |
-| `gv-brainstorming` | Orchestratore: brainstorming, ispezione, pianificazione, delega e review |
-| `refactor-rename` | Rinomina sicura di identificatori su file e cartelle (script incluso, con dry-run) |
-| `review-fix-loop` | Ciclo review→fix ripetuto fino a zero violazioni |
-| `session-handoff` | Scrive un HANDOFF.md per passare il lavoro a un altro agente o sessione |
-| `spec-design-format` | Formato obbligatorio per i design spec in `.spec/` |
-| `ssh-manager` | Ispezione di server remoti via plugin MCP ssh-manager (salute, log, DB, processi) |
+| `add-phpengine-plugin` | Create or modify PHP plugins for the phpengine backend, following the official recipe |
+| `ascii-preview-generator` | ASCII visual previews of interfaces, layouts, and UI components |
+| `codebase-analysis` | Analyze and document the architecture of an existing codebase without modifying it |
+| `code-simplifier` | Simplify and refactor code while preserving its behavior |
+| `coding-standard-rules` | Code quality rules applied to every write/edit task |
+| `commit` | Atomic Conventional Commits-style commits + CHANGELOG updates |
+| `create-docs` | Generate structured project documentation (AGENTS.md, specs/) |
+| `format-report-fix` | Standard format for reporting fixes with concrete impact |
+| `frontend-mobile-rules` | Mobile-first frontend rules: MUI, viewport, and iOS safe-area |
+| `brainstorming` | Orchestrator: brainstorming, inspection, planning, delegation, and review |
+| `refactor-rename` | Safe renaming of identifiers across files and folders (script included, with dry-run) |
+| `review-fix-loop` | Repeated review→fix loop until zero violations |
+| `session-handoff` | Writes a HANDOFF.md to hand the work off to another agent or session |
+| `spec-design-format` | Mandatory format for design specs in `.spec/` |
+| `ssh-manager` | Remote server inspection via the ssh-manager MCP plugin (health, logs, DB, processes) |

+ 270 - 0
bin/agent-sync.mjs

@@ -0,0 +1,270 @@
+#!/usr/bin/env node
+// agent-sync — installs the bundled skills and agent prompts into the canonical
+// user-level directory (~/.agents) and links them into every detected AI agent's
+// own directory (same architecture as the vercel-labs/skills CLI).
+// Usage: agent-sync [--check]
+//   --check : dry-run, reports drift without writing anything (exit 1 on drift)
+
+import { createHash } from 'node:crypto';
+import {
+  copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync,
+  readFileSync, realpathSync, rmSync, statSync, symlinkSync, linkSync, writeFileSync,
+} from 'node:fs';
+import { homedir, platform } from 'node:os';
+import { basename, dirname, join, relative, sep } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const IS_WINDOWS = platform() === 'win32';
+const DRY_RUN = process.argv.slice(2).includes('--check');
+
+const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const SOURCE_SKILLS_DIR = join(PACKAGE_ROOT, 'skills');
+const SOURCE_AGENTS_DIR = join(PACKAGE_ROOT, 'agents');
+
+const CANONICAL_SKILLS_DIR = join(homedir(), '.agents', 'skills');
+const CANONICAL_AGENTS_DIR = join(homedir(), '.agents', 'agents');
+
+// Resolves the Claude Code config dir, honoring CLAUDE_CONFIG_DIR when set.
+function claudeBase() {
+  return process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude');
+}
+
+// Resolves a fixed directory under the user home (lazy, re-evaluated per run).
+function homeDir(dir) {
+  return () => join(homedir(), dir);
+}
+
+// Supported AI agents: base() proves installation when the directory exists,
+// skillsSub/agentsSub are the subdirectories receiving our links. Agent prompts
+// are linked only where the convention is verified (claude); pi gets a minimal
+// manifest instead (see writePiManifests) because its manifests carry
+// per-machine config such as model and thinking.
+const AGENT_REGISTRY = [
+  { name: 'claude',     base: claudeBase,                    skillsSub: 'skills', agentsSub: 'agents' },
+  { name: 'pi',         base: homeDir(join('.pi', 'agent')), skillsSub: 'skills', agentsSub: 'agents' },
+  { name: 'codex',      base: homeDir('.codex'),             skillsSub: 'skills' },
+  { name: 'opencode',   base: homeDir('.opencode'),          skillsSub: 'skills' },
+  { name: 'cursor',     base: homeDir('.cursor'),            skillsSub: 'skills' },
+  { name: 'gemini',     base: homeDir('.gemini'),            skillsSub: 'skills' },
+  { name: 'trae',       base: homeDir('.trae'),              skillsSub: 'skills' },
+  { name: 'windsurf',   base: homeDir('.windsurf'),          skillsSub: 'skills' },
+  { name: 'aider-desk', base: homeDir('.aider-desk'),        skillsSub: 'skills' },
+];
+
+// Returns the SHA-256 hex digest of a file, or null when the file does not exist.
+function fileHash(filePath) {
+  if (!existsSync(filePath)) return null;
+  return createHash('sha256').update(readFileSync(filePath)).digest('hex');
+}
+
+// Returns a stable digest of a directory: sorted list of "relative path:hash"
+// for every file, so any content or structure change alters the result.
+function dirHash(dirPath) {
+  if (!existsSync(dirPath)) return null;
+  const parts = [];
+  const walk = (dir, prefix) => {
+    for (const entry of readdirSync(dir, { withFileTypes: true })) {
+      const full = join(dir, entry.name);
+      if (entry.isDirectory()) walk(full, `${prefix}${entry.name}/`);
+      else parts.push(`${prefix}${entry.name}:${fileHash(full)}`);
+    }
+  };
+  walk(dirPath, '');
+  return createHash('sha256').update(parts.sort().join('\n')).digest('hex');
+}
+
+// Returns the lstat of a path, or null when it does not exist (broken links included).
+function lstatOrNull(path) {
+  try {
+    return lstatSync(path);
+  } catch {
+    return null;
+  }
+}
+
+// Tells whether the entry at linkPath is a symlink resolving to targetPath.
+function pointsTo(linkPath, targetPath) {
+  try {
+    return realpathSync(linkPath) === realpathSync(targetPath);
+  } catch {
+    return false;
+  }
+}
+
+// Copies bundled skills and agents into the canonical directory, replacing
+// entries whose content differs. Returns the list of performed actions.
+function syncCanonical() {
+  const actions = [];
+  for (const entry of readdirSync(SOURCE_SKILLS_DIR, { withFileTypes: true })) {
+    if (!entry.isDirectory()) continue;
+    const source = join(SOURCE_SKILLS_DIR, entry.name);
+    const dest = join(CANONICAL_SKILLS_DIR, entry.name);
+    const status = dirHash(source) === dirHash(dest) ? 'up-to-date' : existsSync(dest) ? 'updated' : 'added';
+    if (status !== 'up-to-date' && !DRY_RUN) {
+      rmSync(dest, { recursive: true, force: true });
+      cpSync(source, dest, { recursive: true });
+    }
+    actions.push({ label: `skills/${entry.name}`, status });
+  }
+  for (const file of readdirSync(SOURCE_AGENTS_DIR).filter((name) => name.endsWith('.md'))) {
+    const source = join(SOURCE_AGENTS_DIR, file);
+    const dest = join(CANONICAL_AGENTS_DIR, file);
+    const status = fileHash(source) === fileHash(dest) ? 'up-to-date' : existsSync(dest) ? 'updated' : 'added';
+    if (status !== 'up-to-date' && !DRY_RUN) {
+      copyFileSync(source, dest);
+    }
+    actions.push({ label: `agents/${file}`, status });
+  }
+  return actions;
+}
+
+// Detects installed agents by probing their config directory.
+function detectAgents() {
+  return AGENT_REGISTRY
+    .filter((agent) => existsSync(agent.base()))
+    .map((agent) => ({ ...agent, baseDir: agent.base() }));
+}
+
+// Ensures linkPath points to targetPath. Existing entries are never modified:
+// a link already pointing at the target counts as up-to-date, anything else is
+// foreign and skipped. New links use the best mechanism available: relative
+// symlink (unix), junction for directories (windows, no privileges), then
+// hardlink and copy as windows file fallbacks.
+function ensureLink(linkPath, targetPath) {
+  const current = lstatOrNull(linkPath);
+  if (current) {
+    if (current.isSymbolicLink() && pointsTo(linkPath, targetPath)) return 'up-to-date';
+    // Regular files produced by this tool's own windows fallbacks (hardlink,
+    // copy) converge to up-to-date when their content matches the target.
+    // Missing or non-file targets degrade to 'skipped': in dry-run the
+    // canonical target may not be installed yet.
+    if (current.isFile() && lstatOrNull(targetPath)?.isFile() && fileHash(linkPath) === fileHash(targetPath)) {
+      return 'up-to-date';
+    }
+    return 'skipped';
+  }
+  if (DRY_RUN) return 'link';
+  mkdirSync(dirname(linkPath), { recursive: true });
+  const isDirectory = statSync(targetPath).isDirectory();
+  if (!IS_WINDOWS) {
+    symlinkSync(relative(dirname(linkPath), targetPath), linkPath);
+    return 'linked';
+  }
+  if (isDirectory) {
+    symlinkSync(targetPath, linkPath, 'junction');
+    return 'linked';
+  }
+  try {
+    symlinkSync(relative(dirname(linkPath), targetPath), linkPath, 'file');
+    return 'linked';
+  } catch {
+    // Windows symlinks need Developer Mode: degrade to hardlink (same volume),
+    // then to a plain copy so installation always succeeds.
+    try {
+      linkSync(targetPath, linkPath);
+      return 'hardlinked';
+    } catch {
+      copyFileSync(targetPath, linkPath);
+      return 'copied';
+    }
+  }
+}
+
+// Links every bundled skill and agent prompt (source package names only, so
+// third-party content in the canonical directory is never propagated) into one
+// detected agent's directories.
+function linkForAgent(agent) {
+  const actions = [];
+  for (const name of readdirSync(SOURCE_SKILLS_DIR, { withFileTypes: true })
+    .filter((entry) => entry.isDirectory())
+    .map((entry) => entry.name)) {
+    actions.push({
+      label: `${agent.name}:skills/${name}`,
+      status: ensureLink(join(agent.baseDir, agent.skillsSub, name), join(CANONICAL_SKILLS_DIR, name)),
+    });
+  }
+  if (agent.agentsSub && agent.name !== 'pi') {
+    for (const file of readdirSync(SOURCE_AGENTS_DIR).filter((name) => name.endsWith('.md'))) {
+      actions.push({
+        label: `${agent.name}:agents/${file}`,
+        status: ensureLink(join(agent.baseDir, agent.agentsSub, file), join(CANONICAL_AGENTS_DIR, file)),
+      });
+    }
+  }
+  return actions;
+}
+
+// Creates a minimal pi manifest for each bundled agent prompt, only when absent:
+// existing manifests hold per-machine config (model, thinking, tools) that must
+// never be overwritten. Only package-sourced agents are considered, so
+// third-party prompts in the canonical directory never receive a manifest.
+function writePiManifests(piAgent) {
+  const piAgentsDir = join(piAgent.baseDir, piAgent.agentsSub);
+  const actions = [];
+  for (const file of readdirSync(SOURCE_AGENTS_DIR).filter((name) => name.endsWith('.md'))) {
+    const manifestPath = join(piAgentsDir, file);
+    if (lstatOrNull(manifestPath)) {
+      actions.push({ label: `pi-manifest:${file}`, status: 'up-to-date' });
+      continue;
+    }
+    if (!DRY_RUN) {
+      const promptPath = join(CANONICAL_AGENTS_DIR, file).split(sep).join('/');
+      const heading = readFileSync(join(SOURCE_AGENTS_DIR, file), 'utf8')
+        .split('\n')
+        .find((line) => line.startsWith('# '));
+      const description = heading ? heading.replace(/^#+\s*/, '').trim() : 'Installed by agent-sync';
+      mkdirSync(piAgentsDir, { recursive: true });
+      writeFileSync(manifestPath, `---\nname: ${JSON.stringify(basename(file, '.md'))}\ndescription: ${JSON.stringify(description)}\nprompt: ${JSON.stringify(promptPath)}\n---\n`);
+    }
+    actions.push({ label: `pi-manifest:${file}`, status: 'manifest' });
+  }
+  return actions;
+}
+
+// Orchestrates the sync and prints a per-entry report with a final summary.
+function main() {
+  const args = process.argv.slice(2);
+  if (args.some((arg) => arg !== '--check')) {
+    console.error('Usage: agent-sync [--check]');
+    process.exit(2);
+  }
+  if (!existsSync(SOURCE_SKILLS_DIR) || !existsSync(SOURCE_AGENTS_DIR)) {
+    console.error(`error: bundled content not found under ${PACKAGE_ROOT}`);
+    process.exit(1);
+  }
+  if (!DRY_RUN) {
+    mkdirSync(CANONICAL_SKILLS_DIR, { recursive: true });
+    mkdirSync(CANONICAL_AGENTS_DIR, { recursive: true });
+  }
+
+  const report = [...syncCanonical()];
+  const agents = detectAgents();
+  for (const agent of agents) {
+    report.push(...linkForAgent(agent));
+  }
+  const piAgent = agents.find((agent) => agent.name === 'pi');
+  if (piAgent) {
+    report.push(...writePiManifests(piAgent));
+  }
+
+  for (const { label, status } of report) {
+    console.log(`${status.padEnd(12)} ${label}`);
+  }
+  console.log(`\nagents detected: ${agents.map((agent) => agent.name).join(', ') || 'none'}`);
+  const drift = report.filter((entry) => entry.status !== 'up-to-date' && entry.status !== 'skipped').length;
+  if (drift === 0) {
+    console.log('Everything up to date.');
+  } else {
+    console.log(DRY_RUN ? `${drift} entr${drift === 1 ? 'y' : 'ies'} out of sync (dry-run, nothing written).` : `${drift} entr${drift === 1 ? 'y' : 'ies'} synced.`);
+  }
+  if (DRY_RUN && drift > 0) {
+    process.exit(1);
+  }
+}
+
+try {
+  main();
+} catch (error) {
+  console.error(`error: ${error.message}`);
+  process.exit(1);
+}

+ 17 - 0
package.json

@@ -0,0 +1,17 @@
+{
+  "name": "agent-sync",
+  "version": "0.1.0",
+  "description": "Installs bundled skills and agent prompts into the canonical ~/.agents directory and links them into every detected AI agent (Claude Code, pi, Codex, OpenCode, Cursor, Gemini, Trae, Windsurf, Aider Desk).",
+  "type": "module",
+  "bin": {
+    "agent-sync": "bin/agent-sync.mjs"
+  },
+  "files": [
+    "bin",
+    "agents",
+    "skills"
+  ],
+  "engines": {
+    "node": ">=18"
+  }
+}