UNPKG

@agentled/cli

Version:

CLI for Agentled — manage workflows, apps, and knowledge from the command line. Zero context-window cost for AI agents.

370 lines 16.4 kB
/** * Skills installer — copies the bundled Agentled Claude Code skills into the * user's `~/.claude/skills/` (global) or `./.claude/skills/` (project) dir. * * Why: When an agent drives the CLI with a fresh Claude Code session, it does * not have the Agentled skill loaded unless the skill is present on disk in a * Claude-discoverable location. Without the skill, the LLM invents invalid * step types (`type: "ai"`, `knowledge_graph_query`, …) — the exact class of * silent CLI failure that MCP-025 is closing. * * The installer is version-aware: * - Reads a `version:` frontmatter field from each `SKILL.md` * - Leaves newer or hand-edited skills alone (unless --force) * - Reports status so we can print a one-line summary after `auth login` * * Targets: * - Claude Code / Claude Desktop → `~/.claude/skills/` (global) or * `./.claude/skills/` (project) via the `global` option. * - Codex → `~/.codex/skills/` via `targetDir` override (set by the * `agentled setup` orchestrator after detecting a Codex install). * - Cursor / Windsurf → not auto-installed (no native skills surface); * the orchestrator skips this step and prints a hint. */ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, cpSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; export function resolveBundledSkillsDir() { const here = fileURLToPath(import.meta.url); const pkgRoot = resolve(dirname(here), '..', '..'); return join(pkgRoot, 'skills'); } function resolveBundledCodexPluginHooksDir() { const here = fileURLToPath(import.meta.url); const pkgRoot = resolve(dirname(here), '..', '..'); return join(pkgRoot, 'codex-plugin-hooks'); } export function getSkillsTargetDir(global) { return global ? join(homedir(), '.claude', 'skills') : join(process.cwd(), '.claude', 'skills'); } /** Codex's skill directory — separate from Claude's. */ export function getCodexSkillsDir() { return join(homedir(), '.codex', 'skills'); } /** OpenClaw's managed/local skills directory (`~/.openclaw/skills/<skill>/SKILL.md`). */ export function getOpenClawSkillsDir() { return join(homedir(), '.openclaw', 'skills'); } /** * Hermes Agent's user skills directory. Hermes nests skills by category * (`~/.hermes/skills/<category>/<skill>/SKILL.md`), so we install under an * `automation` category. */ export function getHermesSkillsDir() { return join(homedir(), '.hermes', 'skills', 'automation'); } function parseSkillVersion(skillMdPath) { try { const content = readFileSync(skillMdPath, 'utf-8'); const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); if (!fmMatch) return null; const versionMatch = fmMatch[1].match(/^version:\s*(.+)$/m); return versionMatch ? versionMatch[1].trim() : null; } catch { return null; } } function compareVersions(a, b) { const parse = (v) => v.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0); const [a1, a2 = 0, a3 = 0] = parse(a); const [b1, b2 = 0, b3 = 0] = parse(b); if (a1 !== b1) return a1 < b1 ? -1 : 1; if (a2 !== b2) return a2 < b2 ? -1 : 1; if (a3 !== b3) return a3 < b3 ? -1 : 1; return 0; } function directoriesIdentical(a, b) { try { const aEntries = readdirSync(a).sort(); const bEntries = readdirSync(b).sort(); if (aEntries.length !== bEntries.length) return false; for (let i = 0; i < aEntries.length; i++) { if (aEntries[i] !== bEntries[i]) return false; const aChild = join(a, aEntries[i]); const bChild = join(b, bEntries[i]); const aStat = statSync(aChild); const bStat = statSync(bChild); if (aStat.isDirectory() !== bStat.isDirectory()) return false; if (aStat.isDirectory()) { if (!directoriesIdentical(aChild, bChild)) return false; } else { const aBuf = readFileSync(aChild); const bBuf = readFileSync(bChild); if (!aBuf.equals(bBuf)) return false; } } return true; } catch { return false; } } export function installSkills(options = {}) { const sourceDir = resolveBundledSkillsDir(); if (!existsSync(sourceDir)) { throw new Error(`Bundled skills directory not found at ${sourceDir}`); } const targetBase = options.targetDir ?? getSkillsTargetDir(options.global ?? false); const results = []; const skills = readdirSync(sourceDir, { withFileTypes: true }) .filter(d => d.isDirectory()) .map(d => d.name); for (const skill of skills) { const src = join(sourceDir, skill); const dest = join(targetBase, skill); const bundledVersion = parseSkillVersion(join(src, 'SKILL.md')) ?? '0.0.0'; const installedVersion = existsSync(dest) ? parseSkillVersion(join(dest, 'SKILL.md')) : null; let outcome; if (!existsSync(dest)) { outcome = 'installed'; } else if (options.force) { outcome = 'forced'; } else if (installedVersion && compareVersions(installedVersion, bundledVersion) > 0) { outcome = 'newer-local'; } else if (installedVersion && compareVersions(installedVersion, bundledVersion) === 0) { outcome = directoriesIdentical(src, dest) ? 'up-to-date' : 'hand-edited'; } else { outcome = 'updated'; } if (outcome === 'installed' || outcome === 'updated' || outcome === 'forced') { mkdirSync(dest, { recursive: true }); cpSync(src, dest, { recursive: true, force: true }); } results.push({ skill, outcome, bundledVersion, installedVersion, targetPath: dest, }); } return results; } export function describeSkillsInstall(results, targetDir) { if (results.length === 0) return 'No skills to install.'; const lines = []; for (const r of results) { switch (r.outcome) { case 'installed': lines.push(` ✓ ${r.skill} v${r.bundledVersion} installed`); break; case 'updated': lines.push(` ✓ ${r.skill} v${r.installedVersion} → v${r.bundledVersion} updated`); break; case 'forced': lines.push(` ✓ ${r.skill} v${r.bundledVersion} installed (forced)`); break; case 'up-to-date': lines.push(` • ${r.skill} v${r.bundledVersion} already installed`); break; case 'newer-local': lines.push(` • ${r.skill} local v${r.installedVersion} is newer than bundled v${r.bundledVersion} — left as-is`); break; case 'hand-edited': lines.push(` • ${r.skill} v${r.bundledVersion} already installed with local edits — left as-is (run with --force to overwrite)`); break; } } lines.push(` Skills directory: ${targetDir}`); return lines.join('\n'); } /** * Produce a short one-line hint for the `auth login` summary, e.g. * "Skill installed: agentled v0.2.0 → ~/.claude/skills/" * "Skill already installed (v0.1.0), latest is v0.2.0 — run `agentled skills update` to refresh." * * Returns null if there is nothing worth showing. */ export function summarizeForLoginBanner(results, targetDir) { if (results.length === 0) return null; const fresh = results.filter(r => r.outcome === 'installed'); const stale = results.filter(r => r.outcome === 'newer-local' || r.outcome === 'hand-edited'); const updatable = results.filter(r => r.outcome === 'up-to-date' && r.installedVersion && r.bundledVersion && compareVersions(r.installedVersion, r.bundledVersion) < 0); if (fresh.length > 0) { const names = fresh.map(r => `${r.skill} v${r.bundledVersion}`).join(', '); return `Installed Agentled skill: ${names}${targetDir}`; } if (stale.length > 0) { const r = stale[0]; return `Skill "${r.skill}" already installed (v${r.installedVersion ?? '?'}), bundled is v${r.bundledVersion} — run \`agentled skills update\` to refresh.`; } if (updatable.length > 0) { const r = updatable[0]; return `Skill "${r.skill}" v${r.installedVersion} installed, v${r.bundledVersion} available — run \`agentled skills update\` to refresh.`; } return null; } /** * Targets that read a directory of `SKILL.md` files (multi-file bundle: router * + references/). We install the full bundle here, same as Claude/Codex. */ export const SKILL_DIR_TARGETS = ['claude', 'codex', 'openclaw', 'hermes']; /** * Targets that read a single markdown context file. We flatten the router + * every reference into one self-contained file for these. */ export const NON_NATIVE_TARGETS = ['cursor', 'agents', 'gemini']; function parseFrontmatterField(skillMd, field) { const fm = skillMd.match(/^---\n([\s\S]*?)\n---/); if (!fm) return null; const m = fm[1].match(new RegExp(`^${field}:\\s*(.+)$`, 'm')); return m ? m[1].trim() : null; } function stripFrontmatter(skillMd) { return skillMd.replace(/^---\n[\s\S]*?\n---\n?/, '').trimStart(); } /** * Build one self-contained markdown document from a skill directory: the * SKILL.md router body followed by every references/*.md, concatenated under * dividers. No path resolution needed at read time — everything is inline. */ export function buildCombinedSkillMarkdown(skillDir) { const skillMdPath = join(skillDir, 'SKILL.md'); if (!existsSync(skillMdPath)) { throw new Error(`No SKILL.md found in ${skillDir}`); } const router = readFileSync(skillMdPath, 'utf-8'); const parts = [stripFrontmatter(router)]; const refsDir = join(skillDir, 'references'); if (existsSync(refsDir)) { const refs = readdirSync(refsDir).filter(f => f.endsWith('.md')).sort(); for (const ref of refs) { parts.push(`\n\n---\n\n<!-- reference: references/${ref} -->\n\n` + readFileSync(join(refsDir, ref), 'utf-8').trim()); } } return parts.join('\n').trimEnd() + '\n'; } /** Enumerate the composable reference modules of a skill for `agentled skills list`. */ export function listSkillModules(skillDir) { const refsDir = join(skillDir, 'references'); if (!existsSync(refsDir)) return []; const out = []; for (const f of readdirSync(refsDir).filter(n => n.endsWith('.md')).sort()) { const body = readFileSync(join(refsDir, f), 'utf-8'); const title = body.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? f; // Grab the first contiguous blockquote block (the "> Loaded on demand…" blurb) // and strip the per-line "> " prefixes. const loadWhen = body.match(/(?:^>.*\n?)+/m)?.[0] ?.replace(/^>\s?/gm, '') .replace(/\s+/g, ' ') .trim() ?? ''; out.push({ file: `references/${f}`, title, loadWhen }); } return out; } /** Where each non-native target's context file lives (project-relative). */ function targetExportPath(target, projectDir) { switch (target) { case 'cursor': return join(projectDir, '.cursor', 'rules', 'agentled.mdc'); case 'agents': return join(projectDir, 'AGENTS.md'); case 'gemini': return join(projectDir, 'GEMINI.md'); default: throw new Error(`Target ${target} is not a single-file export target`); } } /** * Export the combined skill markdown for a non-native target. Non-destructive: * if a conventional file (AGENTS.md / GEMINI.md) already exists, write a * sidecar (`agentled.AGENTS.md`) and report it rather than clobbering the * user's file. Cursor rules are agentled-owned, so they're always (re)written. */ export function exportSkillToTarget(target, skillDir, projectDir, opts = {}) { const combined = buildCombinedSkillMarkdown(skillDir); const description = parseFrontmatterField(readFileSync(join(skillDir, 'SKILL.md'), 'utf-8'), 'description') ?? 'Agentled workflow automation skill.'; if (target === 'cursor') { const path = targetExportPath('cursor', projectDir); mkdirSync(dirname(path), { recursive: true }); const mdc = `---\ndescription: ${description}\nalwaysApply: false\n---\n\n${combined}`; writeFileSync(path, mdc); return { target, path, action: 'written' }; } // agents / gemini — conventional shared files; don't clobber. const path = targetExportPath(target, projectDir); const heading = `<!-- agentled-skill:start -->\n# Agentled\n\n${combined}\n<!-- agentled-skill:end -->\n`; if (existsSync(path) && !opts.force) { const sidecar = join(projectDir, `agentled.${target === 'agents' ? 'AGENTS' : 'GEMINI'}.md`); writeFileSync(sidecar, heading); return { target, path: sidecar, action: 'written-sidecar' }; } writeFileSync(path, heading); return { target, path, action: 'written' }; } /** * Scaffold a Codex *plugin* package (distinct from a bare `~/.codex/skills` * drop): a directory with `.codex-plugin/plugin.json` and the full skill bundle * under `skills/<name>/`. The manifest's `description` drives Codex's implicit * skill invocation. The result is a local plugin the user installs via Codex's * "Install plugin" flow or a local marketplace. * * Schema: https://developers.openai.com/codex/plugins/build */ export function buildCodexPlugin(skillDir, outDir) { const skillMd = readFileSync(join(skillDir, 'SKILL.md'), 'utf-8'); const version = parseFrontmatterField(skillMd, 'version') ?? '0.0.0'; const description = parseFrontmatterField(skillMd, 'description') ?? 'Agentled workflow automation skill.'; const pluginDir = join(outDir, 'agentled-codex-plugin'); const manifestDir = join(pluginDir, '.codex-plugin'); mkdirSync(manifestDir, { recursive: true }); const manifest = { name: 'agentled', version, description, author: { name: 'Agentled', url: 'https://www.agentled.ai' }, homepage: 'https://www.agentled.ai/en', repository: 'https://github.com/Agentled/mcp-server', license: 'MIT', keywords: ['workflow', 'automation', 'agents', 'lead-enrichment', 'crm'], skills: './skills/', interface: { displayName: 'Agentled', shortDescription: 'Build, run, and improve Agentled AI workflows', longDescription: 'Give Codex the Agentled workflow-authoring playbook, approval rules, validation loop, and runtime guidance for safe business automation.', developerName: 'Agentled', category: 'Productivity', capabilities: ['Interactive', 'Read', 'Write'], websiteURL: 'https://www.agentled.ai/en', privacyPolicyURL: 'https://www.agentled.ai/en/privacy-policy', termsOfServiceURL: 'https://www.agentled.ai/en/terms-of-use', defaultPrompt: [ 'Create an Agentled workflow', 'Validate and publish my workflow', 'Inspect this Agentled execution', ], brandColor: '#111827', }, }; const manifestPath = join(manifestDir, 'plugin.json'); writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); // Copy the full skill bundle (SKILL.md + references/) into skills/agentled/. cpSync(skillDir, join(pluginDir, 'skills', 'agentled'), { recursive: true, force: true }); const codexPluginHooksDir = resolveBundledCodexPluginHooksDir(); if (!existsSync(codexPluginHooksDir)) { throw new Error(`Bundled Codex plugin hooks directory not found at ${codexPluginHooksDir}`); } cpSync(codexPluginHooksDir, join(pluginDir, 'hooks'), { recursive: true, force: true }); return { pluginDir, manifestPath }; } //# sourceMappingURL=skills.js.map