@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
JavaScript
/**
* 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